From f74ea706cc49c9a950686220938674fcc32c3ef0 Mon Sep 17 00:00:00 2001 From: Vladeva Date: Mon, 6 Jul 2026 13:41:02 +0200 Subject: [PATCH] calculate and display score --- senatus/src/api/event/controllers/event.ts | 113 +++++++++++------- .../api/submission/controllers/submission.ts | 56 ++++++++- .../src/api/training/controllers/training.ts | 52 +++++++- senatus/src/utils/scoring.ts | 78 ++++++++++++ .../SubmissionResult/SubmissionHeader.tsx | 29 ++++- .../SubmissionResult/SubmissionResult.tsx | 9 +- .../SubmissionStatusBadge.tsx | 2 +- web/src/pages/CompetePage/types/Submission.ts | 2 + .../Tabs/ProblemsTab/ProblemsTab.tsx | 4 +- .../Tabs/ProblemsTab/Table/ProblemStats.tsx | 8 +- .../Tabs/ProblemsTab/Table/TableHeader.tsx | 2 +- .../Tabs/ProblemsTab/Table/TableRow.tsx | 3 +- web/src/pages/EventDetail/types.ts | 4 + web/src/pages/TrainingPage.tsx | 10 +- 14 files changed, 309 insertions(+), 63 deletions(-) create mode 100644 senatus/src/utils/scoring.ts diff --git a/senatus/src/api/event/controllers/event.ts b/senatus/src/api/event/controllers/event.ts index d2001cf..e9f7f1a 100644 --- a/senatus/src/api/event/controllers/event.ts +++ b/senatus/src/api/event/controllers/event.ts @@ -10,6 +10,7 @@ import { isRegistrationOpenNow, } from '../../../utils/event-registration'; import { getCurrentUser } from '../../../utils/current-user'; +import { calculateSubmissionScore, calculateProblemSubmissionStats } from '../../../utils/scoring'; type LeaderboardVisibilityMode = 'public_only_live' | 'full_live'; @@ -50,10 +51,11 @@ type SubmissionLike = { username?: string; email?: string; }; - problem?: { + problem?: ProblemLike; + executions?: ExecutionLike[]; + event?: { documentId: string; }; - executions?: ExecutionLike[]; }; export type TrainingProblemLike = { @@ -103,12 +105,12 @@ type EventQuestionLike = { const DEFAULT_POINTS = 100; -const getSafeWeight = (value?: number) => +export const getSafeWeight = (value?: number) => typeof value === 'number' && value > 0 ? value : 1; -const round2 = (value: number) => Math.round(value * 100) / 100; +export const round2 = (value: number) => Math.round(value * 100) / 100; -const isExecutionPassed = (execution?: ExecutionLike) => { +export const isExecutionPassed = (execution?: ExecutionLike) => { if (!execution?.processed) { return false; } @@ -122,7 +124,7 @@ const isExecutionPassed = (execution?: ExecutionLike) => { return stdout.length > 0 && stdout === expected; }; -const shouldUseTestCaseInLiveScore = ( +export const shouldUseTestCaseInLiveScore = ( problem: ProblemLike, testCase: TestCaseLike, eventEnded: boolean, @@ -278,9 +280,63 @@ export default factories.createCoreController('api::event.event', ({ strapi }) = return ctx.notFound('Event not found'); } - response.data = sanitizeEventForCompetitionView(event) as any; + const eventEnded = event.end ? new Date(event.end).getTime() <= Date.now() : false; + + const submissions = (await strapi.documents('api::submission.submission').findMany({ + filters: { + event: { + documentId: event.documentId + }, + user: { + documentId: user.documentId + } + }, + populate: { + event:true, + problem: { + populate: { + testCases: true + } + }, + executions: { + populate: { + testCase: true + } + } + }, + sort: ['createdAt:asc'], + pagination: { + page: 1, + pageSize: 10000, + }, + }) as SubmissionLike[]); + + const sanitizedEvent = sanitizeEventForCompetitionView(event) as any; + sanitizedEvent.problems = (sanitizedEvent.problems || []).map((problem: any) => { + const submissionsForProblem=submissions + .filter((submission) => submission.problem?.documentId === problem.documentId) + .map((submission: any) => { + const result = calculateSubmissionScore(submission.problem, submission.executions || [], eventEnded); + return { + ...submission, + score: result.score, + maxScore: result.problemMaxScore + } + } + ); + return { + ...problem, + ...calculateProblemSubmissionStats(submissionsForProblem) + } + }); + + response.data=sanitizedEvent; return response; + + /*response.data = sanitizeEventForCompetitionView(event) as any; + + return response;*/ }, async registrationStatus(ctx) { @@ -831,45 +887,12 @@ export default factories.createCoreController('api::event.event', ({ strapi }) = if (!problem) { continue; } - - const scopedCases = (problem.testCases || []).filter((testCase) => - shouldUseTestCaseInLiveScore(problem, testCase, eventEnded), - ); - - const totalWeight = scopedCases.reduce( - (sum, testCase) => sum + getSafeWeight(testCase.weight), - 0, - ); - - const executionMap = new Map( - (submission.executions || []) - .filter((execution) => execution.testCase?.documentId) - .map((execution) => [execution.testCase!.documentId, execution]), + + const { score, totalExecutionTime, problemMaxScore } = calculateSubmissionScore( + problem, + submission.executions || [], + eventEnded, ); - - let passedWeight = 0; - let totalExecutionTime = 0; - - for (const testCase of scopedCases) { - const execution = executionMap.get(testCase.documentId); - if (!execution?.processed) { - continue; - } - - const testWeight = getSafeWeight(testCase.weight); - - if (isExecutionPassed(execution)) { - passedWeight += testWeight; - } - - if (typeof execution.executionTime === 'number' && execution.executionTime >= 0) { - totalExecutionTime += execution.executionTime; - } - } - - const problemMaxScore = problem.points || DEFAULT_POINTS; - const rawScore = totalWeight > 0 ? (passedWeight / totalWeight) * problemMaxScore : 0; - const score = round2(rawScore); const createdAtTs = submission.createdAt ? new Date(submission.createdAt).getTime() : Number.MAX_SAFE_INTEGER; const key = `${submission.user.documentId}::${problem.documentId}`; diff --git a/senatus/src/api/submission/controllers/submission.ts b/senatus/src/api/submission/controllers/submission.ts index 912c4a9..2e8cc20 100644 --- a/senatus/src/api/submission/controllers/submission.ts +++ b/senatus/src/api/submission/controllers/submission.ts @@ -6,6 +6,7 @@ import { connect } from '@colosseum/queue'; import { factories } from '@strapi/strapi' import { canUserRegisterForEvent, normalizeComparableIdentifiers } from '../../../utils/event-registration'; import { getCurrentUser } from '../../../utils/current-user'; +import { calculateSubmissionScore } from '../../../utils/scoring'; let queueClientPromise: Promise>> | null = null; @@ -263,6 +264,32 @@ const sanitizeExecutionForParticipant = (execution: any) => { return nextExecution; }; +const isEventEnded = (event: any) => { + if (!event?.end) + return false; + const endMs = new Date(event.end).getTime(); + return !Number.isNaN(endMs) && endMs <= Date.now(); +}; + +const submissionWithScore = (submission: any) => { + if (!submission?.problem) { + return submission; + } + + const eventEnded = isEventEnded(submission.problem.event); + + const { score, problemMaxScore } = calculateSubmissionScore( + submission.problem, + submission.executions || [], + eventEnded, + ); + + return { + ...submission, + score, + maxScore: problemMaxScore, + }; +}; const sanitizeExecutionsForParticipant = (executions: any[]) => (executions || []) @@ -274,9 +301,16 @@ const sanitizeSubmissionForParticipant = (submission: any) => { return submission; } + const withScore =submissionWithScore(submission); + + if(!Array.isArray(withScore.executions)) + { + return withScore; + } + return { - ...submission, - executions: sanitizeExecutionsForParticipant(submission.executions), + ...withScore, + executions: sanitizeExecutionsForParticipant(withScore.executions), }; }; @@ -299,7 +333,23 @@ export default factories.createCoreController('api::submission.submission', ({ s if (!user) { return ctx.unauthorized('Authentication required'); } - + ctx.query = { + ...ctx.query, + populate: { + ...(typeof ctx.query.populate === 'object' ? ctx.query.populate : {}), + language: true, + user: true, + problem: { + populate: { + testCases: true, + event: { fields: ['end'] }, + }, + }, + executions: { + populate: { testCase: true }, + }, + }, + }; const response = (await super.find(ctx)) as any; const entries = Array.isArray(response?.data) ? response.data diff --git a/senatus/src/api/training/controllers/training.ts b/senatus/src/api/training/controllers/training.ts index e5d92fe..7445cf3 100644 --- a/senatus/src/api/training/controllers/training.ts +++ b/senatus/src/api/training/controllers/training.ts @@ -4,10 +4,14 @@ import { factories } from '@strapi/strapi'; import { TrainingProblemLike } from '../../event/controllers/event'; +import { calculateSubmissionScore } from '../../../utils/scoring'; +import { calculateProblemSubmissionStats } from '../../../utils/scoring'; +import { getCurrentUser } from '../../../utils/current-user'; export default { async trainingProblems(ctx) { const now = Date.now(); + const user = await getCurrentUser(strapi, ctx); const endedEvents = await strapi.documents('api::event.event').findMany({ filters: { @@ -19,10 +23,54 @@ export default { }); const problems = ((endedEvents || []) as any[]) .flatMap((event) => (event.problems || []) - .map((problem: any) => ({...problem, eventId: event.id, eventTitle: event.title}))) + .map((problem: any) => ({...problem, eventId: event.documentId, eventTitle: event.title}))) .filter((problem: any) => !!problem?.documentId) as TrainingProblemLike[]; //*/ - ctx.body = problems; + const rawSubmissions = await strapi.documents('api::submission.submission').findMany({ + filters: { + user: { + documentId: user.documentId, + }, + }, + populate: { + problem: { + fields: ['documentId', 'points'], + populate: { + testCases: true, + event: { + fields: ['end'] + } + } + }, + executions: { + populate: { + testCase: true + } + } + }, + sort: ['createdAt:asc'] + }); + const submissions = rawSubmissions.filter((submission: any) => submission?.metadata?.mode !== 'competition') + + const submissionsWithScore = submissions.map((submission : any) => { + const result = calculateSubmissionScore(submission.problem, submission.executions || [], true); + return { + ...submission, + score: result.score, + maxScore: result.problemMaxScore + } + }); + const problemsWithScores = problems.map((problem) => { + const submissionsForProblem = submissionsWithScore.filter( + (submission : any) => submission.problem.documentId === problem.documentId + ); + const bestSubmission = calculateProblemSubmissionStats(submissionsForProblem); + return { + ...problem, + ...bestSubmission + } + }) + ctx.body = problemsWithScores; }, }; diff --git a/senatus/src/utils/scoring.ts b/senatus/src/utils/scoring.ts new file mode 100644 index 0000000..1b78594 --- /dev/null +++ b/senatus/src/utils/scoring.ts @@ -0,0 +1,78 @@ +import { isExecutionPassed, shouldUseTestCaseInLiveScore, getSafeWeight, round2 } from '../api/event/controllers/event'; +export const DEFAULT_POINTS = 100; +export const calculateSubmissionScore = ( + problem: { documentId: string; points?: number; testCases?: any[] }, + executions: any[], + eventEnded: boolean, +) => { + const scopedCases = (problem.testCases || []).filter((testCase) => + shouldUseTestCaseInLiveScore(problem, testCase, eventEnded), + ); + + const totalWeight = scopedCases.reduce( + (sum, testCase) => sum + getSafeWeight(testCase.weight), + 0, + ); + + const executionMap = new Map( + (executions || []) + .filter((execution) => execution.testCase?.documentId) + .map((execution) => [execution.testCase!.documentId, execution]), + ); + + let passedWeight = 0; + let totalExecutionTime = 0; + let allProcessed = true; + + for (const testCase of scopedCases) { + const execution = executionMap.get(testCase.documentId); + if (!execution?.processed) { + allProcessed=false; + continue; + } + + const testWeight = getSafeWeight(testCase.weight); + + if (isExecutionPassed(execution)) { + passedWeight += testWeight; + } + + if (typeof execution.executionTime === 'number' && execution.executionTime >= 0) { + totalExecutionTime += execution.executionTime; + } + } + + const problemMaxScore = problem.points || DEFAULT_POINTS; + const rawScore = totalWeight > 0 ? (passedWeight / totalWeight) * problemMaxScore : 0; + return { + score: round2(rawScore), + problemMaxScore, + totalExecutionTime, + allProcessed + } +}; +export const calculateProblemSubmissionStats = (submissions: any[]) => { + if (submissions.length === 0) { + return { + lastSubmission: null, + bestSubmission: null + } + } + let lastSubmission = submissions[submissions.length-1]; + let bestSubmission = submissions[0]; + for(const submission of submissions) + { + if(bestSubmission.score @@ -18,6 +22,27 @@ export default function SubmissionHeader({ {new Date(submission.createdAt).toLocaleTimeString()} •{' '} {submission.language?.name}

+ {hasUnprocessed ? ( +
+ + Evaluating + + +
+
+
+
+
+
+ ) : ( +

Score: {score} / {maxScore} pts

+ )}
) } \ No newline at end of file diff --git a/web/src/pages/CompetePage/components/LeftPanel/SubmissionResult/SubmissionResult.tsx b/web/src/pages/CompetePage/components/LeftPanel/SubmissionResult/SubmissionResult.tsx index 44d05e1..7bd3d12 100644 --- a/web/src/pages/CompetePage/components/LeftPanel/SubmissionResult/SubmissionResult.tsx +++ b/web/src/pages/CompetePage/components/LeftPanel/SubmissionResult/SubmissionResult.tsx @@ -70,7 +70,11 @@ export default function SubmissionResult({ + submission={submission} + score={submission.score} + maxScore={submission.maxScore} + hasUnprocessed={hasUnprocessed} + />
diff --git a/web/src/pages/CompetePage/components/LeftPanel/SubmissionResult/SubmissionStatusBadge.tsx b/web/src/pages/CompetePage/components/LeftPanel/SubmissionResult/SubmissionStatusBadge.tsx index 593da75..94d8b5e 100644 --- a/web/src/pages/CompetePage/components/LeftPanel/SubmissionResult/SubmissionStatusBadge.tsx +++ b/web/src/pages/CompetePage/components/LeftPanel/SubmissionResult/SubmissionStatusBadge.tsx @@ -32,7 +32,7 @@ export default function SubmissionStatusBadge({ "text-xs", passedCount === executionsLength ? "border-emerald-500/50 text-emerald-500" - : "border-destructive/50 text-destructive" + : passedCount<=0 ? "border-destructive/50 text-destructive" : "border-amber-500/50 text-amber-500" )} > {passedCount}/{executionsLength} diff --git a/web/src/pages/CompetePage/types/Submission.ts b/web/src/pages/CompetePage/types/Submission.ts index 77addee..59c1ba3 100644 --- a/web/src/pages/CompetePage/types/Submission.ts +++ b/web/src/pages/CompetePage/types/Submission.ts @@ -14,4 +14,6 @@ export interface Submission { codeName: string; }; executions: Execution[]; + score: number; + maxScore: number; } \ No newline at end of file diff --git a/web/src/pages/EventDetail/Tabs/ProblemsTab/ProblemsTab.tsx b/web/src/pages/EventDetail/Tabs/ProblemsTab/ProblemsTab.tsx index 01e2c95..904bb37 100644 --- a/web/src/pages/EventDetail/Tabs/ProblemsTab/ProblemsTab.tsx +++ b/web/src/pages/EventDetail/Tabs/ProblemsTab/ProblemsTab.tsx @@ -33,7 +33,7 @@ export default function ProblemsTab({ isRegisteredForEvent, isViewOnlyEvent, vis }; useEffect(() => { - if (!eventId || !event || !isRegisteredForEvent) { + if (!eventId || !isRegisteredForEvent) { setProblemStatusById({}); return; } @@ -119,7 +119,7 @@ export default function ProblemsTab({ isRegisteredForEvent, isViewOnlyEvent, vis }; fetchProblemStatuses(); - }, [eventId, event, isRegisteredForEvent]); + }, [eventId, isRegisteredForEvent]); const getProblemStatusClass = (status: ProblemStatus | undefined) => { switch (status) { diff --git a/web/src/pages/EventDetail/Tabs/ProblemsTab/Table/ProblemStats.tsx b/web/src/pages/EventDetail/Tabs/ProblemsTab/Table/ProblemStats.tsx index 6d36266..9238bb1 100644 --- a/web/src/pages/EventDetail/Tabs/ProblemsTab/Table/ProblemStats.tsx +++ b/web/src/pages/EventDetail/Tabs/ProblemsTab/Table/ProblemStats.tsx @@ -7,10 +7,14 @@ type ProblemStats = { difficulty: string | undefined; points: number | undefined; problemStatusClass: string; + lastSubmission?: { + score: number, + maxScore: number + } | null } -export default function ProblemStats({ difficulty, points, problemStatusClass}: ProblemStats) { +export default function ProblemStats({ difficulty, points, problemStatusClass, lastSubmission}: ProblemStats) { return ( <> @@ -19,7 +23,7 @@ export default function ProblemStats({ difficulty, points, problemStatusClass}: - {points ?? "N/A"} + {lastSubmission?.score ?? "-"} / {points ?? "N/A"} # Problem Difficulty - Points + Score Status diff --git a/web/src/pages/EventDetail/Tabs/ProblemsTab/Table/TableRow.tsx b/web/src/pages/EventDetail/Tabs/ProblemsTab/Table/TableRow.tsx index eb41088..daf1bc3 100644 --- a/web/src/pages/EventDetail/Tabs/ProblemsTab/Table/TableRow.tsx +++ b/web/src/pages/EventDetail/Tabs/ProblemsTab/Table/TableRow.tsx @@ -41,7 +41,8 @@ export default function TableRowComponent({ problem, index, isRegisteredForEvent + problemStatusClass={problemStatusClass} + lastSubmission={problem.lastSubmission} /> diff --git a/web/src/pages/EventDetail/types.ts b/web/src/pages/EventDetail/types.ts index 4c25a6e..8178ac5 100644 --- a/web/src/pages/EventDetail/types.ts +++ b/web/src/pages/EventDetail/types.ts @@ -11,6 +11,10 @@ export interface Problem { checkerSource?: string; leaderboardVisibilityMode?: 'public_only_live' | 'full_live'; testCases?: any[]; + lastSubmission: { + score: number, + maxScore: number + } } export interface SupportedLanguage { diff --git a/web/src/pages/TrainingPage.tsx b/web/src/pages/TrainingPage.tsx index 04966a9..8322297 100644 --- a/web/src/pages/TrainingPage.tsx +++ b/web/src/pages/TrainingPage.tsx @@ -7,6 +7,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { ChevronRight, Circle } from 'lucide-react'; import { cn } from '@/lib/utils'; +interface SubmissionStats { + score: number; + maxScore: number; +} + interface EventItem { documentId: string; title: string; @@ -15,6 +20,7 @@ interface EventItem { eventTitle: string; difficulty?: string; points: number; + bestSubmission?: SubmissionStats | null; } interface SubmissionExecution { @@ -232,7 +238,7 @@ export default function TrainingPage() { Problem Event - Points + Score Difficulty Status @@ -252,7 +258,7 @@ export default function TrainingPage() { {item.eventTitle} - {item.points ?? '—'} + {item.bestSubmission?.score ?? '-'} / {item.points ?? '—'} {item.difficulty ? (