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 ? (
+
+ ) : (
+ 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 ? (