From 15969b2efa91167adefda3a5b7f8668929dce541 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:19:36 +0200 Subject: [PATCH 1/5] feat(judging): live results (remove RESULTS LOCKED) + count all saved scores The RESULTS tab no longer hides the standings behind a lock. The leaderboard now counts every saved score (not only finalized ballots), so it's live as judges score and an admin's own scores show up immediately. 'complete' (every submission scored) is informational and gates only PUBLISH; a LIVE banner shows coverage. Admins add scores via the SCORE tab as before; they now appear here. 438 server tests pass (leaderboard + sim journey updated for live behavior); client build + lint clean. --- .../admin/ProgramJudgingSection.tsx | 33 +++++++++---------- client/src/lib/api.ts | 16 ++++++--- .../__tests__/scoring.service.test.js | 28 +++++++++------- server/api/services/scoring.service.js | 31 +++++++++-------- server/sim/SIMULATION_REPORT.md | 4 +-- server/sim/__tests__/judging-journey.test.js | 18 +++++----- 6 files changed, 71 insertions(+), 59 deletions(-) diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index b322974..49cebb8 100644 --- a/client/src/components/admin/ProgramJudgingSection.tsx +++ b/client/src/components/admin/ProgramJudgingSection.tsx @@ -1,5 +1,5 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; -import { Loader2, ExternalLink, Lock, Trophy, Plus, ChevronRight, ChevronDown, Play, Trash2 } from "lucide-react"; +import { Loader2, ExternalLink, Trophy, Plus, ChevronRight, ChevronDown, Play, Trash2 } from "lucide-react"; import { api, type AdminAuthArg, @@ -712,33 +712,30 @@ export function ProgramJudgingSection({ )} ) - ) : !board ? ( -

No results yet.

- ) : board.locked ? ( -
-
-
-

- {board.submissionsScored} of {board.submissionsTotal} submissions scored. Winners can be selected once every - submission has a score from a submitted judge. -

- {board.pendingJudges.length > 0 && ( -

Judges still finishing: {board.pendingJudges.join(", ")}

- )} -
+ ) : !board || board.rows.length === 0 ? ( +

No submissions yet.

) : ( <> + {!board.complete && ( +
+ ·LIVE — {board.submissionsScored}/{board.submissionsTotal} SUBMISSIONS SCORED + {board.pendingJudges.length > 0 && ( + · judges still finishing: {board.pendingJudges.join(", ")} + )} + · publish unlocks once every submission is scored +
+ )}
-
{canSelectWinners && ( + )} + {r.githubUrl && ( + + GIT + + )} + +
{fmt(r.avgTotal)} {fmt(r.avgRequirements)} From 2febe0450fd619ac008f13168471fead7ebcade5 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:40:02 +0200 Subject: [PATCH 5/5] feat(judging): score a project inline from the RESULTS view (edit + re-save) Expand a results row to get your own score inputs (pre-filled from your saved score) and a SAVE/RE-SAVE button, so a judge can score or revise a project straight from the final results without going back to the batch flow. Adds myScore per leaderboard row (the viewing judge's own score). --- .../admin/ProgramJudgingSection.tsx | 75 +++++++++++++++++++ client/src/lib/api.ts | 4 +- .../api/controllers/submission.controller.js | 2 +- .../__tests__/scoring.service.test.js | 15 ++++ server/api/services/scoring.service.js | 15 +++- 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index 37926c2..c0bec9d 100644 --- a/client/src/components/admin/ProgramJudgingSection.tsx +++ b/client/src/components/admin/ProgramJudgingSection.tsx @@ -86,6 +86,45 @@ export function ProgramJudgingSection({ const [selectedBatches, setSelectedBatches] = useState>(new Set()); const [openBatch, setOpenBatch] = useState(null); const [deletingId, setDeletingId] = useState(null); + // Inline score edits made from the RESULTS view (keyed by submission id). + const [resultDrafts, setResultDrafts] = useState>({}); + const [savingScoreId, setSavingScoreId] = useState(null); + + const setResultField = ( + id: string, + field: "requirements" | "techStack" | "innovation", + raw: string, + current: { requirements: number; techStack: number; innovation: number }, + ) => + setResultDrafts((prev) => { + const cur = prev[id] ?? current; + const n = Math.max(0, Math.min(BOUNDS[field], round1(Number(raw) || 0))); + return { ...prev, [id]: { ...cur, [field]: n } }; + }); + + // Save the viewing judge's own score for one project straight from RESULTS. + const saveResultScore = async ( + submissionId: string, + current: { requirements: number; techStack: number; innovation: number }, + ) => { + const draft = resultDrafts[submissionId] ?? current; + setSavingScoreId(submissionId); + try { + const auth = await getAuth(); + await api.upsertScore(programSlug, submissionId, draft, auth); + toast({ title: "Score saved" }); + await loadLeaderboard(); // refresh averages + ranking + setResultDrafts((prev) => { + const next = { ...prev }; + delete next[submissionId]; + return next; + }); + } catch (e) { + toast({ title: "Couldn't save score", description: (e as Error)?.message || "Unknown error", variant: "destructive" }); + } finally { + setSavingScoreId(null); + } + }; const handleExportCsv = async () => { setExporting(true); @@ -876,6 +915,42 @@ export function ProgramJudgingSection({ + {(() => { + const myCur = r.myScore ?? { requirements: 0, techStack: 0, innovation: 0 }; + const d = resultDrafts[r.submissionId] ?? myCur; + return ( +
+
·YOUR SCORE — EDIT + SAVE
+
+ {(["requirements", "techStack", "innovation"] as const).map((field) => ( + + ))} + TOTAL {round1(d.requirements + d.techStack + d.innovation)}/12 + +
+
+ ); + })()}
·SCORES PER JUDGE
{(r.judgeScores ?? []).length === 0 ? ( No individual scores. diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 259d9c2..7ac45a9 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -305,8 +305,10 @@ export type ApiLeaderboardRow = { avgTechStack: number; avgInnovation: number; judgeCount: number; - /** Individual per-judge scores (submitted judges only) for the breakdown view. */ + /** Individual per-judge scores for the breakdown view. */ judgeScores?: { judgeEmail: string; requirements: number; techStack: number; innovation: number; total: number }[]; + /** The viewing judge's own score (null = not yet scored), for inline edit + re-save. */ + myScore?: { requirements: number; techStack: number; innovation: number; notes: string } | null; /** Current prize on this submission (null = not a winner). */ prizeAmount?: number | null; prizeCurrency?: string | null; diff --git a/server/api/controllers/submission.controller.js b/server/api/controllers/submission.controller.js index 198d17e..6142d96 100644 --- a/server/api/controllers/submission.controller.js +++ b/server/api/controllers/submission.controller.js @@ -547,7 +547,7 @@ class SubmissionController { try { const programId = await resolveProgramId(req); if (!programId) return res.status(404).json({ status: 'error', message: 'Program not found' }); - const data = await scoringService.leaderboard(programId); + const data = await scoringService.leaderboard(programId, judgeIdentity(req)); res.status(200).json({ status: 'success', data }); } catch (error) { console.error('❌ Error building leaderboard:', error); diff --git a/server/api/services/__tests__/scoring.service.test.js b/server/api/services/__tests__/scoring.service.test.js index d26fd36..1802e9e 100644 --- a/server/api/services/__tests__/scoring.service.test.js +++ b/server/api/services/__tests__/scoring.service.test.js @@ -98,6 +98,21 @@ describe('scoringService.leaderboard — tally + per-judge breakdown', () => { expect(r.rows[1].judgeScores.every((s) => s.judgeEmail.includes('@'))).toBe(true); }); + it("includes the viewing judge's own score per row (for inline edit from results)", async () => { + emailRepo.listJudges.mockResolvedValue([{ email: 'a@x.com' }, { email: 'b@x.com' }]); + ballotRepo.listSubmitted.mockResolvedValue([]); + submissionRepo.listByProgramId.mockResolvedValue([{ id: 's1', projectTitle: 'Alpha' }, { id: 's2', projectTitle: 'Beta' }]); + scoreRepo.listByProgramId.mockResolvedValue([ + score('s1', 'a@x.com', 2, 5, 5), + score('s2', 'b@x.com', 1, 1, 1), + ]); + + const r = await scoringService.leaderboard('prog-1', 'a@x.com'); + const byId = Object.fromEntries(r.rows.map((row) => [row.submissionId, row])); + expect(byId.s1.myScore).toMatchObject({ requirements: 2, techStack: 5, innovation: 5 }); + expect(byId.s2.myScore).toBeNull(); // a@x.com didn't score s2 + }); + it('breaks ties by innovation then tech stack', async () => { emailRepo.listJudges.mockResolvedValue([{ email: 'a@x.com' }]); ballotRepo.listSubmitted.mockResolvedValue([{ judgeEmail: 'a@x.com' }]); diff --git a/server/api/services/scoring.service.js b/server/api/services/scoring.service.js index c30782b..55eda14 100644 --- a/server/api/services/scoring.service.js +++ b/server/api/services/scoring.service.js @@ -236,7 +236,7 @@ class ScoringService { // every project. When unlocked, ranks submissions by the mean of each submitted // judge's total (/12), with per-criterion means, the individual per-judge // scores, and tie-breaks on innovation then tech stack. - async leaderboard(programId) { + async leaderboard(programId, judgeEmail = null) { const [registeredJudges, submittedBallots, submissions, allScores, signupEmails] = await Promise.all([ programAdminEmailRepository.listJudges(programId), programJudgeBallotRepository.listSubmitted(programId), @@ -266,9 +266,17 @@ class ScoringService { const submissionsScored = submissions.filter((s) => (scoresBySubmission.get(s.id) || []).length > 0).length; const complete = submissionsTotal > 0 && submissionsScored === submissionsTotal; + // The viewing judge's own score per submission, so they can edit + re-save + // straight from the results view. + const me = normalizeEmail(judgeEmail); + const myScores = new Map( + allScores.filter((sc) => normalizeEmail(sc.judgeEmail) === me).map((sc) => [sc.submissionId, sc]), + ); + const rows = submissions .map((s) => { const scores = scoresBySubmission.get(s.id) || []; + const my = myScores.get(s.id); const avgRequirements = mean(scores.map((x) => x.requirements)); const avgTechStack = mean(scores.map((x) => x.techStack)); const avgInnovation = mean(scores.map((x) => x.innovation)); @@ -293,6 +301,11 @@ class ScoringService { innovation: x.innovation, total: x.requirements + x.techStack + x.innovation, })), + // The viewing judge's own score (null if they haven't scored it), so + // they can edit + re-save it from the results view. + myScore: my + ? { requirements: my.requirements, techStack: my.techStack, innovation: my.innovation, notes: my.notes ?? '' } + : null, // Current prize (winner) on this submission, so the results tab can // render selections against the rank order. Null = not a winner. prizeAmount: s.prizeAmount ?? null,