·BATCHES ({view.batchSize ?? 10} EACH)
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..198d17e 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) {
From b0f6d80ddfcb55135a5f76b68985c11e19ef6584 Mon Sep 17 00:00:00 2001
From: sacha <23283108+sacha-l@users.noreply.github.com>
Date: Wed, 17 Jun 2026 19:30:09 +0200
Subject: [PATCH 3/5] =?UTF-8?q?fix(judging):=20don't=20count=20wallet/admi?=
=?UTF-8?q?n=20'preview'=20scores=20=E2=80=94=20only=20email=20judges?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
My earlier 'count all saved scores' change started counting wallet/admin
sessions as a separate judge, so someone who is both an admin wallet and an
email judge (e.g. sacha@) was double-counted in the leaderboard and breakdown.
Count only email-judge scores (judgeEmail contains '@'); wallet sessions remain
admin previews that never count. Applies to the leaderboard + the live overview.
---
.../services/__tests__/scoring.service.test.js | 16 +++++++---------
server/api/services/scoring.service.js | 12 ++++++++++--
2 files changed, 17 insertions(+), 11 deletions(-)
diff --git a/server/api/services/__tests__/scoring.service.test.js b/server/api/services/__tests__/scoring.service.test.js
index 5d29733..d26fd36 100644
--- a/server/api/services/__tests__/scoring.service.test.js
+++ b/server/api/services/__tests__/scoring.service.test.js
@@ -73,7 +73,7 @@ describe('scoringService.leaderboard — coverage gate', () => {
});
describe('scoringService.leaderboard — tally + per-judge breakdown', () => {
- it('complete on full coverage, counts every saved score (incl. non-submitted), ranks by mean', 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([
@@ -84,20 +84,18 @@ describe('scoringService.leaderboard — tally + per-judge breakdown', () => {
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), // 6
- score('s2', 'b@x.com', 1, 3, 2), // 6
- score('s2', '5WalletAdmin', 2, 5, 5), // 12 -> now counted (live)
+ 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 });
- // s2 now includes the wallet-admin score: mean (6+6+12)/3 = 8, 3 judges.
- expect(r.rows[1]).toMatchObject({ rank: 2, submissionId: 's2', avgTotal: 8, judgeCount: 3 });
- expect(r.rows[1].judgeScores).toHaveLength(3);
- expect(r.rows[0].judgeScores).toContainEqual(
- expect.objectContaining({ judgeEmail: 'a@x.com', total: 12 }),
- );
+ // 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 });
+ expect(r.rows[1].judgeScores).toHaveLength(2);
+ expect(r.rows[1].judgeScores.every((s) => s.judgeEmail.includes('@'))).toBe(true);
});
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 21e2380..c30782b 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, []);
@@ -244,12 +250,14 @@ class ScoringService {
const submittedEmails = new Set(submittedBallots.map((b) => normalizeEmail(b.judgeEmail)));
const pendingJudges = registeredEmails.filter((e) => !submittedEmails.has(e));
- // Count every saved score so the results view is LIVE as judges score (not
- // only after they finalize a ballot) and an admin's own scores show up.
+ // 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 (!isEmailJudge(score.judgeEmail)) continue;
if (!scoresBySubmission.has(score.submissionId)) scoresBySubmission.set(score.submissionId, []);
scoresBySubmission.get(score.submissionId).push(score);
}
From 4386f7e03881721e02bf82fd5240162ea7e1798d Mon Sep 17 00:00:00 2001
From: sacha <23283108+sacha-l@users.noreply.github.com>
Date: Wed, 17 Jun 2026 19:33:21 +0200
Subject: [PATCH 4/5] feat(judging): add VIDEO + GitHub links to each results
row
The RESULTS leaderboard rows showed only title + scores. Add inline VIDEO
(opens the review modal) and GIT links per row so they're accessible without
expanding. Links stop row-click propagation so they don't toggle the breakdown.
---
.../admin/ProgramJudgingSection.tsx | 37 +++++++++++++++----
1 file changed, 30 insertions(+), 7 deletions(-)
diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx
index 51b5686..37926c2 100644
--- a/client/src/components/admin/ProgramJudgingSection.tsx
+++ b/client/src/components/admin/ProgramJudgingSection.tsx
@@ -794,13 +794,36 @@ export function ProgramJudgingSection({
{r.rank}
- {r.projectTitle}
- {r.late && (
- ·LATE
- )}
- {r.eligible === false && (
- ·NOT IN LUMA
- )}
+
+
{r.projectTitle}
+ {r.late && (
+
·LATE
+ )}
+ {r.eligible === false && (
+
·NOT IN LUMA
+ )}
+
e.stopPropagation()}>
+ {r.videoUrl && (
+ setReview({ url: r.videoUrl!, title: r.projectTitle })}
+ className="label-hw-dim hover:text-display inline-flex items-center gap-1"
+ >
+ VIDEO
+
+ )}
+ {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) => (
+
+
+ {field === "requirements" ? "REQ /2" : field === "techStack" ? "TECH /5" : "INNOV /5"}
+
+ setResultField(r.submissionId, field, e.target.value, myCur)}
+ className="w-20 font-mono text-[13px] bg-panel-deep border border-hairline text-display px-2 py-1.5 focus:outline-none focus:border-display"
+ />
+
+ ))}
+ TOTAL {round1(d.requirements + d.techStack + d.innovation)}/12
+ saveResultScore(r.submissionId, myCur)}
+ disabled={savingScoreId === r.submissionId}
+ className="font-mono text-[10px] tracking-[0.14em] border border-display bg-display text-shell hover:bg-display-dim disabled:opacity-50 px-3 py-1.5"
+ >
+ {savingScoreId === r.submissionId ? "SAVING…" : r.myScore ? "RE-SAVE ▸" : "SAVE SCORE ▸"}
+
+
+
+ );
+ })()}
·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,