diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index b322974..c0bec9d 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, @@ -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); @@ -539,7 +578,7 @@ export function ProgramJudgingSection({ <> {locked && (
- ·SUBMITTED — YOUR SCORES COUNT. YOU CAN STILL REVISE + SAVE. + ·SUBMITTED — SCORES COUNT. YOU CAN STILL REVISE, AND CLAIM + SCORE MORE BATCHES. {view.ballotProgress && ( · {view.ballotProgress.submitted} of {view.ballotProgress.total} judges in @@ -552,8 +591,9 @@ export function ProgramJudgingSection({ )} {/* Batch overview: preview each batch's submissions, see who's working - on it, and claim one or several. */} - {!locked && view.batches && view.batches.length > 0 && ( + on it, and claim one or several. Available even after submitting, + so a judge can keep going through more batches. */} + {view.batches && view.batches.length > 0 && (
·BATCHES ({view.batchSize ?? 10} EACH) @@ -712,33 +752,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)} @@ -855,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 bad04a6..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; @@ -315,10 +317,18 @@ export type ApiLeaderboardRow = { paid?: boolean; }; -export type ApiLeaderboard = - // Locked = not yet full coverage (some submission has no score from a submitted judge). - | { locked: true; submissionsScored: number; submissionsTotal: number; pendingJudges: string[] } - | { locked: false; submitted: number; total: number; rows: ApiLeaderboardRow[] }; +/** Live standings. `rows` is always present; `complete` (every submission has + * a score) gates publishing — it no longer hides the table. */ +export type ApiLeaderboard = { + locked: boolean; + complete: boolean; + submissionsScored: number; + submissionsTotal: number; + submitted: number; + total: number; + pendingJudges: string[]; + rows: ApiLeaderboardRow[]; +}; /** One row in the unified program inbox (signups + applications merged). */ export type ApiInboxEntry = { diff --git a/server/api/controllers/__tests__/submission.controller.test.js b/server/api/controllers/__tests__/submission.controller.test.js index d9552c8..3df73dd 100644 --- a/server/api/controllers/__tests__/submission.controller.test.js +++ b/server/api/controllers/__tests__/submission.controller.test.js @@ -365,12 +365,13 @@ describe('SubmissionController.claimBatch (judge)', () => { body, }); - it('409s when the ballot is already submitted', async () => { - ballotRepo.isSubmitted.mockResolvedValue(true); + it('allows claiming more batches even after the ballot is submitted', async () => { + ballotRepo.isSubmitted.mockResolvedValue(true); // already submitted + scoringService.claimBatch.mockResolvedValue({ claimed: 3, view: { batches: [] } }); const res = mockRes(); - await submissionController.claimBatch(judgeReq(), res); - expect(res.status).toHaveBeenCalledWith(409); - expect(scoringService.claimBatch).not.toHaveBeenCalled(); + await submissionController.claimBatch(judgeReq({ batchNumber: 3 }), res); + expect(res.status).toHaveBeenCalledWith(200); + expect(scoringService.claimBatch).toHaveBeenCalledWith('bitrefill', 'judge@x.com', 3); }); it('claims and returns the refreshed view', async () => { diff --git a/server/api/controllers/submission.controller.js b/server/api/controllers/submission.controller.js index ae3e9fe..6142d96 100644 --- a/server/api/controllers/submission.controller.js +++ b/server/api/controllers/submission.controller.js @@ -328,9 +328,9 @@ class SubmissionController { const programId = await resolveProgramId(req); if (!programId) return res.status(404).json({ status: 'error', message: 'Program not found' }); const judgeEmail = judgeIdentity(req); - if (await programJudgeBallotRepository.isSubmitted(programId, judgeEmail)) { - return res.status(409).json({ status: 'error', message: 'Your ballot is submitted and locked.' }); - } + // A submitted ballot no longer locks claiming: scores count as soon as they + // are saved, so a judge (or admin) can keep claiming + scoring more batches + // after submitting their first. const batchNumber = req.body?.batchNumber; const result = await scoringService.claimBatch(programId, judgeEmail, batchNumber); if (result.invalid) { @@ -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 49de29e..1802e9e 100644 --- a/server/api/services/__tests__/scoring.service.test.js +++ b/server/api/services/__tests__/scoring.service.test.js @@ -43,32 +43,37 @@ beforeEach(() => { }); describe('scoringService.leaderboard — coverage gate', () => { - it('locks until every submission has a score from a submitted judge', async () => { + it('shows live standings (not locked) but flags complete:false until every submission is scored', async () => { ballotRepo.listSubmitted.mockResolvedValue([{ judgeEmail: 'a@x.com' }]); emailRepo.listJudges.mockResolvedValue([{ email: 'a@x.com' }, { email: 'b@x.com' }]); submissionRepo.listByProgramId.mockResolvedValue([{ id: 's1' }, { id: 's2' }]); scoreRepo.listByProgramId.mockResolvedValue([score('s1', 'a@x.com', 2, 5, 5)]); // s2 unscored const r = await scoringService.leaderboard('prog-1'); - expect(r.locked).toBe(true); + expect(r.locked).toBe(false); + expect(r.complete).toBe(false); expect(r.submissionsScored).toBe(1); expect(r.submissionsTotal).toBe(2); expect(r.pendingJudges).toEqual(['b@x.com']); + expect(r.rows).toHaveLength(2); // both submissions shown, s2 at 0 }); - it('locks when no judge has submitted', async () => { + it('shows live standings even when no judge has submitted a ballot', async () => { ballotRepo.listSubmitted.mockResolvedValue([]); + emailRepo.listJudges.mockResolvedValue([]); submissionRepo.listByProgramId.mockResolvedValue([{ id: 's1' }]); scoreRepo.listByProgramId.mockResolvedValue([]); const r = await scoringService.leaderboard('prog-1'); - expect(r.locked).toBe(true); + expect(r.locked).toBe(false); + expect(r.complete).toBe(false); expect(r.submissionsTotal).toBe(1); + expect(r.rows).toHaveLength(1); }); }); describe('scoringService.leaderboard — tally + per-judge breakdown', () => { - it('unlocks on full coverage, ranks by mean total, ignores non-submitted scores', async () => { + it('counts email-judge scores live (excludes wallet/admin preview), ranks by mean', async () => { emailRepo.listJudges.mockResolvedValue([{ email: 'a@x.com' }, { email: 'b@x.com' }]); ballotRepo.listSubmitted.mockResolvedValue([{ judgeEmail: 'a@x.com' }, { judgeEmail: 'b@x.com' }]); submissionRepo.listByProgramId.mockResolvedValue([ @@ -78,20 +83,34 @@ describe('scoringService.leaderboard — tally + per-judge breakdown', () => { scoreRepo.listByProgramId.mockResolvedValue([ score('s1', 'a@x.com', 2, 5, 5), // 12 score('s1', 'b@x.com', 2, 5, 3), // 10 -> mean 11 - score('s2', 'a@x.com', 1, 3, 2), - score('s2', 'b@x.com', 1, 3, 2), // mean 6 - score('s2', '5WalletAdmin', 2, 5, 5), // not submitted -> ignored + score('s2', 'a@x.com', 1, 3, 2), // 6 + score('s2', 'b@x.com', 1, 3, 2), // 6 -> mean 6 + score('s2', '5WalletAdmin', 2, 5, 5), // wallet (no @) -> NOT counted ]); const r = await scoringService.leaderboard('prog-1'); expect(r.locked).toBe(false); + expect(r.complete).toBe(true); expect(r.rows[0]).toMatchObject({ rank: 1, submissionId: 's1', avgTotal: 11, judgeCount: 2 }); + // Wallet-admin preview score is ignored -> s2 stays at 2 judges / mean 6. expect(r.rows[1]).toMatchObject({ rank: 2, submissionId: 's2', avgTotal: 6, judgeCount: 2 }); - // Per-judge breakdown present and excludes the non-submitted wallet-admin. expect(r.rows[1].judgeScores).toHaveLength(2); - expect(r.rows[0].judgeScores).toContainEqual( - expect.objectContaining({ judgeEmail: 'a@x.com', total: 12 }), - ); + 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 () => { diff --git a/server/api/services/scoring.service.js b/server/api/services/scoring.service.js index 2ad27df..55eda14 100644 --- a/server/api/services/scoring.service.js +++ b/server/api/services/scoring.service.js @@ -10,6 +10,11 @@ import projectService from './project.service.js'; const normalizeEmail = (email) => typeof email === 'string' ? email.trim().toLowerCase() : ''; +// A real judge identity is an email. Wallet/admin sessions score under their +// wallet address (no '@') as a non-counting "preview", so the same person who is +// both an admin wallet and an email judge isn't double-counted. +const isEmailJudge = (judgeEmail) => typeof judgeEmail === 'string' && judgeEmail.includes('@'); + const mean = (nums) => (nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0); // Batch N (1-based) holds submissions [(N-1)*BATCH_SIZE .. N*BATCH_SIZE) in the @@ -72,6 +77,7 @@ class ScoringService { const scoredBySubmission = new Map(); const scoresBySubmission = new Map(); for (const sc of allScores) { + if (!isEmailJudge(sc.judgeEmail)) continue; // wallet/admin preview scores don't show if (!scoredBySubmission.has(sc.submissionId)) scoredBySubmission.set(sc.submissionId, []); scoredBySubmission.get(sc.submissionId).push(sc.judgeEmail); if (!scoresBySubmission.has(sc.submissionId)) scoresBySubmission.set(sc.submissionId, []); @@ -230,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), @@ -244,33 +250,33 @@ class ScoringService { const submittedEmails = new Set(submittedBallots.map((b) => normalizeEmail(b.judgeEmail))); const pendingJudges = registeredEmails.filter((e) => !submittedEmails.has(e)); - // Only count scores from judges who actually submitted. Ignores wallet-admin - // "preview" scores and any stale rows. - const counted = submittedEmails; + // Count every email-judge's saved score so results are LIVE as they score + // (no ballot needed). Wallet/admin "preview" scores never count, so someone + // who is both an admin wallet and an email judge isn't double-counted. + // Coverage (`complete`) is informational + gates PUBLISH; it no longer hides + // the standings. const scoresBySubmission = new Map(); for (const score of allScores) { - if (!counted.has(normalizeEmail(score.judgeEmail))) continue; + if (!isEmailJudge(score.judgeEmail)) continue; if (!scoresBySubmission.has(score.submissionId)) scoresBySubmission.set(score.submissionId, []); scoresBySubmission.get(score.submissionId).push(score); } - // Coverage gate: locked until every submission has at least one counted score. const submissionsTotal = submissions.length; const submissionsScored = submissions.filter((s) => (scoresBySubmission.get(s.id) || []).length > 0).length; - const complete = - submittedBallots.length > 0 && submissionsTotal > 0 && submissionsScored === submissionsTotal; - if (!complete) { - return { - locked: true, - submissionsScored, - submissionsTotal, - pendingJudges, - }; - } + 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)); @@ -295,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, @@ -312,7 +323,16 @@ class ScoringService { ) .map((row, i) => ({ rank: i + 1, ...row })); - return { locked: false, submitted: submittedBallots.length, total: registeredEmails.length, rows }; + return { + locked: false, + complete, + submissionsScored, + submissionsTotal, + submitted: submittedBallots.length, + total: registeredEmails.length, + pendingJudges, + rows, + }; } // Public, PII-free results for the program page. Only exposes submissions once diff --git a/server/sim/SIMULATION_REPORT.md b/server/sim/SIMULATION_REPORT.md index ed272f8..d7b6796 100644 --- a/server/sim/SIMULATION_REPORT.md +++ b/server/sim/SIMULATION_REPORT.md @@ -17,8 +17,8 @@ Supabase fake. 9. Judge sees all 3 submissions; all from checked-in attendees (eligible). 10. Out-of-range score (requirements=5 > max 2) rejected (400). 11. Ballot submit blocked until every submission in the claimed batch is scored (409). -12. Leaderboard locked: 0 of 3 submissions covered by a submitted judge. -13. Coverage gate: one judge covering every project unlocks the leaderboard (not all judges required). +12. Live standings: a judge's saved scores appear immediately (no ballot required). +13. Coverage (complete) is informational now; the standings are always visible. 14. Leaderboard ranking (each row carries per-judge scores): 1. Aurora Pay (11.33/12) 2. Comet Bridge (8.67/12) 3. Nimbus Wallet (7.67/12). 15. Winner selection is platform-admin only: a per-program admin is rejected (403). 16. Platform admin assigned prizes: Aurora Pay 500 EUR, Nimbus Wallet 200 EUR, Comet Bridge 100 EUR (Bitrefill giftcards). diff --git a/server/sim/__tests__/judging-journey.test.js b/server/sim/__tests__/judging-journey.test.js index fe67571..5a810cb 100644 --- a/server/sim/__tests__/judging-journey.test.js +++ b/server/sim/__tests__/judging-journey.test.js @@ -336,22 +336,24 @@ describe('Bitrefill judging — basic user journeys', () => { return j; }; - // Before anyone submits -> locked, nothing covered. + // Scores show LIVE as soon as they're saved — no ballot needed, never locked. const j1 = await scoreAllFor(JUDGES[0]); const start = await j1.leaderboard(); - expect(start.body.data.locked).toBe(true); - expect(start.body.data.submissionsScored).toBe(0); // no submitted ballots yet - note(`Leaderboard locked: 0 of ${start.body.data.submissionsTotal} submissions covered by a submitted judge.`); - - // One judge submits -> every submission now has a score from a submitted - // judge -> coverage met -> UNLOCKS even though the other judges haven't. + expect(start.body.data.locked).toBe(false); + expect(start.body.data.complete).toBe(true); // all 3 covered by j1's saved scores + expect(start.body.data.submissionsScored).toBe(start.body.data.submissionsTotal); + const auroraStart = start.body.data.rows.find((r) => r.projectTitle === 'Aurora Pay'); + expect(auroraStart.judgeCount).toBe(1); + note('Live standings: a judge\'s saved scores appear immediately (no ballot required).'); + + // Submitting the ballot still finalizes; the standings enrich as more judges score. await j1.submitBallot(); const afterOne = await j1.leaderboard(); expect(afterOne.body.data.locked).toBe(false); const auroraOne = afterOne.body.data.rows.find((r) => r.projectTitle === 'Aurora Pay'); expect(auroraOne.judgeCount).toBe(1); expect(auroraOne.judgeScores).toHaveLength(1); // per-judge breakdown - note('Coverage gate: one judge covering every project unlocks the leaderboard (not all judges required).'); + note('Coverage (complete) is informational now; the standings are always visible.'); // The other two judges also score + submit -> averages enrich to 3 judges. await (await scoreAllFor(JUDGES[1])).submitBallot();