From a61cc63a1cb4164431052df091f709c19f65a455 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 1 Oct 2026 23:06:46 -0400 Subject: [PATCH 1/3] feat(judging): shared table pool with a 3-minute target and 4-minute cutoff Judges were handed a fixed list at Prepare time. Coverage then depended on exactly who showed up: a no-show's projects were never scored, a late judge got all ~500 projects in table order, and the auto split assumed 12 minutes a project. Claims were unlocked, so two judges could take one table. Judging now runs from a shared pool (routers/judge/dispatch.ts). When a judge asks for a table, the server takes a per-hackathon advisory lock and hands out the eligible project with the fewest looks that the judge has not had and nobody is at, nearest table first among equals. judge_queue rows become visits written at hand-out, so judges can join or leave at any point. Sponsor and special-label judges keep to their pool and their looks are counted separately. Timing: a table is held 4 minutes while the judge walks over, then the clock runs from the NFC tap or card scan. 3:00 is the target; at 4:00 (plus 15s grace) a first score is refused, the look is void and the project goes back into the pool. completeAndNext reports timedOut and hands over the next table. The judge page shows the clock, turns amber at 3:00 and locks the form at 4:00. Prepare judging only syncs submissions now and is safe to rerun while judging is live. assignJudgesToProjects, initializeQueue, queue appends on promote, forceSkip reassignment and the coverage queue builder are gone. Rows built in advance by the old flow are cleared rather than counted as visits. The live board shows unseen projects, active judges and measured time to two looks each. Tested against Postgres 18 (dispatch.db.test.ts, runs when JUDGING_TEST_DATABASE_URL is set): 60 judges asking at once get 60 distinct tables; 500 projects reach exactly two looks each with 15 of 40 judges joining 40 minutes late; no-shows, the cutoff, repeat requests, legacy rows and sponsor tracks. Dispatch takes about 18ms locally. No schema change. --- .../.internal-tests/hackathon-flow.test.ts | 92 +- .../src/.internal-tests/judge-edge.test.ts | 1163 ++++++----------- packages/api/src/routers/judge/admin.ts | 917 ++----------- .../api/src/routers/judge/dispatch.db.test.ts | 295 +++++ .../api/src/routers/judge/dispatch.test.ts | 110 ++ packages/api/src/routers/judge/dispatch.ts | 371 ++++++ .../src/routers/judge/helpers-edge.test.ts | 218 +-- .../api/src/routers/judge/helpers.test.ts | 65 +- packages/api/src/routers/judge/helpers.ts | 119 +- packages/api/src/routers/judge/portal.ts | 703 +++++----- packages/api/src/trpc.ts | 4 - packages/db/src/schemas/judge.ts | 18 +- .../app/(portal)/admin/judging/page.tsx | 93 +- .../(portal)/hackathons/[id]/judge/page.tsx | 200 ++- .../admin/hackathons/JudgeLiveBoard.tsx | 76 +- turbo.json | 7 +- 16 files changed, 1873 insertions(+), 2578 deletions(-) create mode 100644 packages/api/src/routers/judge/dispatch.db.test.ts create mode 100644 packages/api/src/routers/judge/dispatch.test.ts create mode 100644 packages/api/src/routers/judge/dispatch.ts diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index 8640c317..6a920b27 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -22,6 +22,8 @@ vi.mock("@query/db", () => { return { db: { transaction: vi.fn().mockImplementation((callback) => callback(db)), + // The judging dispatch lock (pg_advisory_xact_lock). + execute: vi.fn().mockResolvedValue({ rows: [] }), query: { admins: table("admins"), users: table("users"), @@ -93,6 +95,9 @@ vi.mock("@query/db", () => { orderBy: vi.fn().mockResolvedValue([{ count: 0 }]), groupBy: vi.fn().mockResolvedValue([]), innerJoin: vi.fn().mockImplementation(() => ({ + // Awaited directly: judging dispatch reads every vote this way. + then: (ok: any, err: any) => + Promise.resolve(mockWhereRows()).then(ok, err), innerJoin: vi.fn().mockImplementation(() => ({ where: vi.fn().mockResolvedValue([]), })), @@ -499,7 +504,15 @@ describe("Hackathon end-to-end flow", () => { // these tests are about; scoring something never assigned to you is // covered separately in judge-edge. if (table === "judgeQueue") - return { id: "queue_1", judgeId: "judge_1", projectId: PROJECT }; + return { + id: "queue_1", + judgeId: "judge_1", + projectId: PROJECT, + isCompleted: false, + // Tapped in a minute ago: inside the scoring window. + startedAt: new Date(Date.now() - 60_000), + arrivedAt: new Date(Date.now() - 60_000), + }; return undefined; }); return appRouter.createCaller(createMockCtx("judge_user")); @@ -917,59 +930,63 @@ describe("Hackathon end-to-end flow", () => { scoreSoundness: 5, }; + /** At the table, tapped in a minute ago. */ + const arrived = () => ({ + isCompleted: false, + startedAt: new Date(Date.now() - 60_000), + arrivedAt: new Date(Date.now() - 60_000), + }); + + /** + * The reads dispatch makes through select, in order: assignments and + * projects for the pool, this judge's visits, then every vote and every + * open visit when it has a table to pick. Which table it picks is covered + * against real Postgres in routers/judge/dispatch.db.test.ts. An empty + * pool stops after the third read; queueing more would leak into the + * next test, since clearAllMocks does not drain once-values. + */ + const poolOf = (projects: unknown[]) => { + mockWhereRows + .mockReturnValueOnce([]) + .mockReturnValueOnce(projects) + .mockReturnValueOnce([]); + if (projects.length > 0) + mockWhereRows.mockReturnValueOnce([]).mockReturnValueOnce([]); + }; + it("completes the current project and hands back the next one", async () => { const caller = queueCaller({ - queueItem: { id: QUEUE_A, hackathonId: HACK_A }, - next: { - id: "queue_b", - hackathonId: HACK_A, - project: { id: "project_b", name: "Next Project" }, - }, + queueItem: { id: QUEUE_A, judgeId: "judge_1", hackathonId: HACK_A, ...arrived() }, }); + poolOf([ + { + id: "project_b", + tableNumber: 2, + tracks: null, + challenges: null, + isCreateX: false, + }, + ]); + mockInsert.mockReturnValue([{ id: "queue_b" }]); const res = await caller.judge.completeAndNext(vote); expect(res).toMatchObject({ done: false, nextQueueId: "queue_b", + timedOut: false, }); }); it("reports done when nothing is left in the queue", async () => { const caller = queueCaller({ - queueItem: { id: QUEUE_A, hackathonId: HACK_A }, - next: undefined, + queueItem: { id: QUEUE_A, judgeId: "judge_1", hackathonId: HACK_A, ...arrived() }, }); + poolOf([]); const res = await caller.judge.completeAndNext(vote); expect(res).toMatchObject({ done: true, nextProject: null }); }); - it("moves a skipped project to the end and returns the next", async () => { - const caller = queueCaller({ - middlewareHitsQueue: true, - queueItem: { id: QUEUE_A, hackathonId: HACK_A }, - next: { - id: "queue_b", - hackathonId: HACK_A, - project: { id: "project_b" }, - }, - }); - - const res = await caller.judge.skipProject({ queueId: QUEUE_A }); - expect(res).toMatchObject({ skippedToEnd: false, queueId: "queue_b" }); - }); - - it("cannot skip past the last remaining project", async () => { - const caller = queueCaller({ - middlewareHitsQueue: true, - queueItem: { id: QUEUE_A, hackathonId: HACK_A }, - next: undefined, - }); - - const res = await caller.judge.skipProject({ queueId: QUEUE_A }); - expect(res).toMatchObject({ skippedToEnd: true, queueId: QUEUE_A }); - }); - it("errors when skipping a queue item that does not exist", async () => { const caller = queueCaller({ middlewareHitsQueue: true, @@ -987,11 +1004,12 @@ describe("Hackathon end-to-end flow", () => { middlewareHitsQueue: true, queueItem: { id: QUEUE_A, + judgeId: "judge_1", hackathonId: HACK_A, - projectId: PROJECT, - project: { id: PROJECT, tracks: [] }, + ...arrived(), }, }); + poolOf([]); mockInsert.mockClear(); await caller.judge.forceSkipOvertime({ queueId: QUEUE_A }); diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts index 1f8ce744..421db11e 100644 --- a/packages/api/src/.internal-tests/judge-edge.test.ts +++ b/packages/api/src/.internal-tests/judge-edge.test.ts @@ -1,7 +1,8 @@ /** * Judge-side edge cases: queue ownership, vote/queue consistency, the judging - * window, reassignment on force-skip, bulk assignment coverage and admin - * cleanup paths. + * window and the scoring cutoff, and admin cleanup paths. Which table the + * shared pool hands out is covered against real Postgres in + * routers/judge/dispatch.db.test.ts, not here. * * Tests marked `it.skip` are written against the behaviour the product SHOULD * have. Each one currently fails against the shipped handler; the comment @@ -11,7 +12,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { appRouter } from "../root"; import { cache } from "../middleware/cache"; -import { db } from "@query/db"; +import { db, judgeQueue, judgeVotes } from "@query/db"; // Fully mock the DB at the file level, mirroring hackathon-flow.test.ts. const mockFindFirst = vi.fn(); @@ -58,6 +59,8 @@ vi.mock("@query/db", () => { return { db: { transaction: vi.fn().mockImplementation((callback) => callback(db)), + // The judging dispatch lock (pg_advisory_xact_lock). + execute: vi.fn().mockResolvedValue({ rows: [] }), query: { admins: table("admins"), users: table("users"), @@ -308,6 +311,8 @@ describe("Judge edge cases", () => { hackathon?: Record; myAssignment?: Record; judge?: Record | undefined; + /** The judge's existing vote on the project, if any. */ + vote?: Record; }) => { const nextQueue = seq(opts.queue ?? []); mockFindFirst.mockImplementation((table: string) => { @@ -318,16 +323,54 @@ describe("Judge edge cases", () => { if (table === "hackathons") return opts.hackathon ?? { id: HACK_A }; if (table === "judgeQueue") return nextQueue(); if (table === "judgeAssignments") return opts.myAssignment; + if (table === "judgeVotes") return opts.vote; return undefined; }); }; - /** Every judgeQueue row written by a mutation, flattened. */ - const insertedRows = () => - mockInsert.mock.calls.flatMap((c) => { - const values = c[2]?.[0]; - return Array.isArray(values) ? values : values ? [values] : []; - }); + /** Every row a mutation inserted into `table`, flattened. */ + const insertsInto = (table: unknown) => + mockInsert.mock.calls + .filter((c) => c[1]?.[0] === table) + .flatMap((c) => { + const values = c[2]?.[0]; + return Array.isArray(values) ? values : values ? [values] : []; + }); + + /** A judgeable project as loadPool reads it. */ + const poolRow = (id: string, tableNumber: number) => ({ + id, + tableNumber, + tracks: null, + challenges: null, + isCreateX: false, + }); + + /** + * Feeds the select reads dispatchNext makes, in its order: the assignments + * and projects behind the pool, this judge's own visits, then (only when it + * has to pick a new table) every vote and every open visit. Which table it + * picks is covered against real Postgres in dispatch.db.test.ts; these only + * need it to hand something back. + */ + const wireDispatch = (opts: { + pool: unknown[]; + mine?: unknown[]; + votes?: unknown[]; + open?: unknown[]; + }) => { + mockSelect + .mockReturnValueOnce([]) + .mockReturnValueOnce(opts.pool) + .mockReturnValueOnce(opts.mine ?? []) + .mockReturnValueOnce(opts.votes ?? []) + .mockReturnValueOnce(opts.open ?? []); + }; + + /** Inside the scoring window: tapped in a minute ago. */ + const recent = () => new Date(Date.now() - 60_000); + /** Past the 4:00 cutoff and its grace. */ + const lapsed = () => new Date(Date.now() - 5 * 60_000); // ===================================================================== describe("1. A judge may only act on their own queue", () => { @@ -405,63 +448,104 @@ describe("Judge edge cases", () => { expect(mockInsert).not.toHaveBeenCalled(); }); - // BUG: portal.ts:312-314 returns done for a stale queueId without ever - // asking whether the judge has uncompleted rows left. + // A stale queueId is tolerated (the judge may be retrying), and resolving + // the judge's own slot instead must still lead on to the next table rather + // than reporting judging finished. it("does not declare judging finished when the queue row simply no longer exists", async () => { wireJudge({ - project: { id: PROJECT_A, hackathonId: HACK_A }, + project: { id: PROJECT_B, hackathonId: HACK_A, name: "Still Waiting" }, queue: [ undefined, // The slot this judge owns for the project being scored. The stale - // queueId resolves to nothing, but they are still assigned it. - { id: QUEUE_A }, + // queueId resolves to nothing, but they are still at this table. { - id: "queue_b", + id: QUEUE_A, + judgeId: JUDGE_ID, hackathonId: HACK_A, - project: { id: PROJECT_B, name: "Still Waiting" }, + projectId: PROJECT_A, + isCompleted: false, + startedAt: recent(), + arrivedAt: recent(), }, ], }); + wireDispatch({ + pool: [poolRow(PROJECT_A, 1), poolRow(PROJECT_B, 2)], + mine: [ + { + id: QUEUE_A, + projectId: PROJECT_A, + isCompleted: true, + startedAt: recent(), + arrivedAt: recent(), + completedAt: new Date(), + }, + ], + }); + mockInsert.mockReturnValue([{ id: "queue_b" }]); const res = await judgeCaller().judge.completeAndNext(vote); - expect(res.done).toBe(false); + + expect(res).toMatchObject({ done: false, nextQueueId: "queue_b" }); + expect(insertsInto(judgeVotes)).toHaveLength(1); }); it("returns the same next project when the same completion is submitted twice", async () => { - const nextRow = { - id: "queue_b", + const slot = { + id: QUEUE_A, + judgeId: JUDGE_ID, hackathonId: HACK_A, - project: { id: PROJECT_B, name: "Next Project" }, + projectId: PROJECT_A, + isCompleted: false, + startedAt: recent(), + arrivedAt: recent(), }; + const pool = [poolRow(PROJECT_A, 1), poolRow(PROJECT_B, 2)]; wireJudge({ project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [ - { id: QUEUE_A, judgeId: JUDGE_ID, hackathonId: HACK_A, projectId: PROJECT_A }, - nextRow, - ], + queue: [slot], + }); + wireDispatch({ + pool, + mine: [{ ...slot, isCompleted: true, completedAt: new Date() }], }); + mockInsert.mockReturnValue([{ id: "queue_b" }]); const first = await judgeCaller().judge.completeAndNext(vote); + mockInsert.mockClear(); - // Retry from a second tab: the row is already completed, so the next - // uncompleted row is still the same one. + // Retry from a second tab: the slot is already completed and the vote is + // on record, and the visit the first call opened is still live, so + // dispatch hands that one back rather than opening another. wireJudge({ project: { id: PROJECT_A, hackathonId: HACK_A }, - queue: [ + queue: [{ ...slot, isCompleted: true }], + vote: { id: "vote_1" }, + }); + wireDispatch({ + pool, + mine: [ + { ...slot, isCompleted: true, completedAt: new Date() }, { - id: QUEUE_A, - judgeId: JUDGE_ID, - hackathonId: HACK_A, - projectId: PROJECT_A, - isCompleted: true, + id: "queue_b", + projectId: PROJECT_B, + isCompleted: false, + startedAt: new Date(), + arrivedAt: null, + completedAt: null, }, - nextRow, ], }); const second = await judgeCaller().judge.completeAndNext(vote); expect(first).toMatchObject({ done: false, nextQueueId: "queue_b" }); - expect(second).toMatchObject({ done: false, nextQueueId: "queue_b" }); + expect(second).toMatchObject({ + done: false, + nextQueueId: "queue_b", + timedOut: false, + }); + // Nothing scored twice, no second visit opened. + expect(mockInsert).not.toHaveBeenCalled(); }); }); @@ -497,487 +581,67 @@ describe("Judge edge cases", () => { // ===================================================================== describe("4. skipProject response shape", () => { + const ownSlot = { + id: QUEUE_A, + judgeId: JUDGE_ID, + hackathonId: HACK_A, + projectId: PROJECT_A, + isCompleted: false, + startedAt: recent(), + arrivedAt: null, + }; + it("returns the next project with its name and table number", async () => { wireJudge({ - queue: [ - { id: QUEUE_A, hackathonId: HACK_A }, - { id: QUEUE_A, judgeId: JUDGE_ID, hackathonId: HACK_A, order: 1 }, - { - id: "queue_b", - hackathonId: HACK_A, - project: { id: PROJECT_B, name: "Next Project", tableNumber: 12 }, - }, - ], + project: { id: PROJECT_B, name: "Next Project", tableNumber: 12 }, + queue: [{ id: QUEUE_A, hackathonId: HACK_A }, ownSlot], }); - - const res = await judgeCaller().judge.skipProject({ queueId: QUEUE_A }); - expect(res.skippedToEnd).toBe(false); - expect(res.project).toMatchObject({ name: "Next Project", tableNumber: 12 }); - }); - - // The caller renders a project card either way, so the last-project branch - // has to hand back the joined project, not the judgeQueue row — returning - // the queue row leaves the UI with an empty card on a judge's final table. - it("still returns a named project when only the last project remains", async () => { - wireJudge({ - queue: [ - { id: QUEUE_A, hackathonId: HACK_A }, - // project_id is NOT NULL and the handler loads this row with - // `with: { project: true }`, so a slot always arrives carrying one. - { - id: QUEUE_A, - judgeId: JUDGE_ID, - hackathonId: HACK_A, - order: 1, - projectId: PROJECT_A, - project: { id: PROJECT_A, name: "Last Project", tableNumber: 7 }, - }, - undefined, - ], + wireDispatch({ + pool: [poolRow(PROJECT_A, 1), poolRow(PROJECT_B, 12)], + mine: [{ ...ownSlot, isCompleted: true, completedAt: new Date() }], }); + mockInsert.mockReturnValue([{ id: "queue_b" }]); const res = await judgeCaller().judge.skipProject({ queueId: QUEUE_A }); - expect(res.skippedToEnd).toBe(true); - expect(res.project).toMatchObject({ - name: "Last Project", - tableNumber: 7, - }); - }); - }); - // ===================================================================== - describe("5. forceSkipOvertime reassignment", () => { - /** - * Candidate selection now runs two set-based queries rather than two per - * candidate: who already holds this project, and each judge's uncompleted - * count. The mocks mirror that shape — feeding the old per-candidate - * counts here would make these tests pass without exercising the sort. - */ - const wireForceSkip = (opts: { - myAssignment?: Record; - others: Record[]; - /** judgeIds already holding the skipped project */ - holders?: string[]; - /** judgeId -> uncompleted queue length */ - remaining?: Record; - /** the judge's own next uncompleted slot, if any */ - next?: Record; - }) => { - const nextQueue = seq([ - { id: QUEUE_A, hackathonId: HACK_A }, // isJudge middleware lookup - { - id: QUEUE_A, - judgeId: JUDGE_ID, - hackathonId: HACK_A, - projectId: PROJECT_A, - project: { id: PROJECT_A, tracks: [] }, - }, - // the "what do I do next" lookup at the end - opts.next, - ]); - mockFindFirst.mockImplementation((table: string) => { - if (table === "judges") return JUDGE_ROW; - if (table === "hackathons") return { id: HACK_A }; - if (table === "judgeQueue") return nextQueue(); - if (table === "judgeAssignments") - return "myAssignment" in opts - ? opts.myAssignment - : { judgeId: JUDGE_ID, hackathonId: HACK_A }; - return undefined; - }); - mockFindMany.mockImplementation((table: string) => - table === "judgeAssignments" ? opts.others : [], - ); - mockSelect.mockReturnValueOnce( - (opts.holders ?? []).map((judgeId) => ({ judgeId })), - ); - mockSelect.mockReturnValueOnce( - Object.entries(opts.remaining ?? {}).map(([judgeId, remaining]) => ({ - judgeId, - remaining, - })), - ); - }; - - // BUG: portal.ts:428-486 draws candidates from every judgeAssignments row - // with no isActive/status filter and sorts by fewest remaining, so a judge - // who was never activated (empty queue) sorts first and is handed the - // project it can never open. - it("never hands the project to a judge who is not active", async () => { - wireForceSkip({ - others: [ - { - judgeId: "inactive_judge", - track: null, - status: "pending", - judge: { id: "inactive_judge", isActive: false }, - }, - { - judgeId: "active_judge", - track: null, - status: "approved", - judge: { id: "active_judge", isActive: true }, - }, - ], - remaining: { inactive_judge: 0, active_judge: 4 }, - }); - - await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }); - - const reassigned = insertedRows().find((r: any) => r.projectId === PROJECT_A); - expect(reassigned?.judgeId).toBe("active_judge"); - }); - - // A judge already holding this project must not be handed it twice — they - // would see the same table appear again later in their own queue. - it("never hands the project to a judge who already has it", async () => { - wireForceSkip({ - others: [ - { - judgeId: "has_it", - track: null, - judge: { id: "has_it", isActive: true }, - }, - { - judgeId: "free_judge", - track: null, - judge: { id: "free_judge", isActive: true }, - }, - ], - holders: [JUDGE_ID, "has_it"], - remaining: { has_it: 0, free_judge: 9 }, - }); - - await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }); - - const reassigned = insertedRows().find( - (r: any) => r.projectId === PROJECT_A, - ); - expect(reassigned?.judgeId).toBe("free_judge"); + expect(res).toMatchObject({ done: false, queueId: "queue_b" }); + expect(res.project).toMatchObject({ name: "Next Project", tableNumber: 12 }); }); - // Between two eligible judges the lighter queue wins, so the reassigned - // project is actually reached before judging closes. - it("prefers the judge with the fewest projects left", async () => { - wireForceSkip({ - others: [ - { - judgeId: "busy", - track: null, - judge: { id: "busy", isActive: true }, - }, - { - judgeId: "light", - track: null, - judge: { id: "light", isActive: true }, - }, - ], - remaining: { busy: 11, light: 2 }, + // The pool never sends a judge back to a table they passed on, so skipping + // the last one they can be sent to ends their judging rather than serving + // the same table again. + it("reports done when the skipped table was the last one left", async () => { + wireJudge({ + queue: [{ id: QUEUE_A, hackathonId: HACK_A }, ownSlot], }); - - await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }); - - const reassigned = insertedRows().find( - (r: any) => r.projectId === PROJECT_A, - ); - expect(reassigned?.judgeId).toBe("light"); - }); - - // BUG: portal.ts:422-424 loads myAssignment with no hackathonId filter and - // then uses myAssignment.hackathonId (not queueItem.hackathonId) for the - // reassignment row, orphaning it in the wrong hackathon. - it("files the reassignment under the hackathon the skipped project belongs to", async () => { - wireForceSkip({ - myAssignment: { judgeId: JUDGE_ID, hackathonId: HACK_B }, - others: [ - { - judgeId: "active_judge", - track: null, - status: "approved", - judge: { id: "active_judge", isActive: true }, - }, - ], - remaining: { active_judge: 1 }, + wireDispatch({ + pool: [poolRow(PROJECT_A, 1)], + mine: [{ ...ownSlot, isCompleted: true, completedAt: new Date() }], }); - await judgeCaller().judge.forceSkipOvertime({ queueId: QUEUE_A }); - - const reassigned = insertedRows().find((r: any) => r.projectId === PROJECT_A); - expect(reassigned?.hackathonId).toBe(HACK_A); - }); - - // Both siblings (completeAndNext, skipProject) stamp startedAt on the slot - // they hand over. Without it here the next table stays unclaimed and the - // following judge to ask for work is sent to the table this judge just - // walked up to. - it("claims the table it hands the judge next", async () => { - wireForceSkip({ - others: [], - next: { - id: "queue_next", - judgeId: JUDGE_ID, - hackathonId: HACK_A, - projectId: "project_next", - project: { id: "project_next", tracks: [] }, - }, - }); - - const res = await judgeCaller().judge.forceSkipOvertime({ - queueId: QUEUE_A, - }); + const res = await judgeCaller().judge.skipProject({ queueId: QUEUE_A }); - expect(res.queueId).toBe("queue_next"); - const claimed = mockUpdate.mock.calls.some( - (call: any) => - call[2]?.[0]?.startedAt instanceof Date && - !("isCompleted" in (call[2]?.[0] ?? {})), + expect(res).toMatchObject({ done: true, project: null, queueId: null }); + // The slot is closed without a vote. + const closed = mockUpdate.mock.calls.some( + (call) => call[1]?.[0] === judgeQueue && call[2]?.[0]?.isCompleted === true, ); - expect(claimed).toBe(true); - }); - - // BUG: with no judgeAssignments row the whole reassignment block is - // skipped (portal.ts:426) yet the response still looks like a success, so - // the project is dropped with nobody left to judge it. - it("reports that nothing was reassigned when the judge has no assignment row", async () => { - wireForceSkip({ myAssignment: undefined, others: [] }); - - const res = await judgeCaller().judge.forceSkipOvertime({ - queueId: QUEUE_A, - }); - expect(res).toHaveProperty("reassigned", false); - }); - }); - - // ===================================================================== - describe("6. Bulk assignment coverage", () => { - const projectsFor = (track: string, count: number, from = 1) => - Array.from({ length: count }, (_, i) => ({ - id: `${track}_${i + from}`, - hackathonId: HACK_A, - tracks: [track], - challenges: null, - tableNumber: i + from, - isCreateX: false, - })); - - const wireAssign = (opts: { - hackathonTracks: string[] | null; - assignments: Record[]; - projects: Record[]; - }) => { - mockFindFirst.mockImplementation((table: string) => { - if (table === "admins") return ADMIN_ROW; - if (table === "hackathons") - return { id: HACK_A, name: "Hacklytics", tracks: opts.hackathonTracks }; - return undefined; - }); - mockFindMany.mockImplementation((table: string) => { - if (table === "judgeAssignments") return opts.assignments; - if (table === "judgingProjects") return opts.projects; - return []; - }); - }; - - const runAssign = () => - adminCaller().judge.assignJudgesToProjects({ - hackathonId: HACK_A, - minProjects: 3, - maxProjects: 5, - shuffle: false, - autoCalculate: false, - }); - - const trackedJudges = [ - { judgeId: "j_sports", track: "Sports", judge: { name: "Sports Judge" } }, - { judgeId: "j_finance", track: "Finance", judge: { name: "Finance Judge" } }, - ]; - - it("caps each track judge at maxProjects when the hackathon tracks are configured", async () => { - wireAssign({ - hackathonTracks: ["Sports", "Finance"], - assignments: trackedJudges, - projects: [...projectsFor("Sports", 8), ...projectsFor("Finance", 8, 9)], - }); - - const res = await runAssign(); - - expect(res.success).toBe(true); - for (const a of res.assignments) { - expect(a.assignedCount).toBeLessThanOrEqual(5); - expect(a.assignedCount).toBeGreaterThanOrEqual(3); - } - expect(res.coverage.max).toBe(1); - }); - - // BUG: admin.ts:497 builds MAIN_TRACKS from hackathon.tracks. When that - // column is null every tracked judge is classified "special" - // (helpers.ts:67) and bypasses minProjects/maxProjects entirely. - it("still caps each judge at maxProjects when the hackathon has no tracks configured", async () => { - wireAssign({ - hackathonTracks: null, - assignments: trackedJudges, - projects: [...projectsFor("Sports", 8), ...projectsFor("Finance", 8, 9)], - }); - - const res = await runAssign(); - - for (const a of res.assignments) { - expect(a.assignedCount).toBeLessThanOrEqual(5); - } - }); - - // BUG: admin.ts:499-502 filters allAssignments by hackathonId only, so - // pending applicants whose judges row is isActive:false get real queue - // rows and inflate coverage.min even though isJudge will never let them in. - it("gives no queue to an applicant who has not been approved", async () => { - wireAssign({ - hackathonTracks: ["Sports"], - assignments: [ - { - judgeId: "j_approved", - track: "Sports", - status: "approved", - judge: { name: "Approved", isActive: true }, - }, - { - judgeId: "j_pending", - track: "Sports", - status: "pending", - judge: { name: "Pending", isActive: false }, - }, - ], - projects: projectsFor("Sports", 8), - }); - - const res = await runAssign(); - - const pending = res.assignments.find((a) => a.judgeId === "j_pending"); - expect(pending?.assignedCount).toBe(0); - }); - - // BUG: admin.ts:509-514 rejects zero projects but not zero judges, so an - // event with no judges reports success with coverage {min:0,max:0}. - it("refuses to run an assignment when the hackathon has no judges", async () => { - wireAssign({ - hackathonTracks: ["Sports"], - assignments: [], - projects: projectsFor("Sports", 8), - }); - - await expect(runAssign()).rejects.toThrow(/judge/i); - }); - - it("refuses an assignment run for a hackathon with no projects", async () => { - wireAssign({ - hackathonTracks: ["Sports"], - assignments: trackedJudges, - projects: [], - }); - - await expect(runAssign()).rejects.toThrow(/No projects found/); + expect(closed).toBe(true); + expect(insertsInto(judgeVotes)).toHaveLength(0); }); }); // ===================================================================== - describe("7. initializeQueue track filtering", () => { - const wireInit = ( - track: string, - projects: Record[], - judgeHackathonId: string = HACK_A, - ) => { - mockFindFirst.mockImplementation((table: string) => { - if (table === "admins") return ADMIN_ROW; - // The judge's own edition. initializeQueue reads this to refuse - // building a queue nobody could ever open. - if (table === "judges") return { hackathonId: judgeHackathonId }; - if (table === "judgeAssignments") - return { judgeId: JUDGE_ID, hackathonId: HACK_A, track }; - return undefined; - }); - mockFindMany.mockImplementation((table: string) => - table === "judgingProjects" ? projects : [], - ); - }; - - const pool = [ - { - id: PROJECT_A, - tracks: ["Sports"], - challenges: null, - isCreateX: false, - tableNumber: 1, - }, - { - id: PROJECT_B, - tracks: null, - challenges: null, - isCreateX: true, - tableNumber: 2, - }, - ]; - - it("queues only the projects carrying the judge's track", async () => { - wireInit("Sports", pool); - - const res = await adminCaller().judge.initializeQueue({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - shuffle: false, - }); - - expect(res).toMatchObject({ success: true, projectCount: 1 }); - expect(insertedRows()[0]).toMatchObject({ projectId: PROJECT_A }); - }); - - // BUG: admin.ts:433-440 omits the `track === 'createx' && p.isCreateX` - // branch that assignToHackathon (admin.ts:152) and buildCoverageQueues - // (helpers.ts:72) both apply, so re-initializing a createX judge silently - // wipes their entire workload and still reports success. - it("queues the createX pool for a judge assigned to the createX track", async () => { - wireInit("createX", pool); - - const res = await adminCaller().judge.initializeQueue({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - shuffle: false, - }); - - expect(res.projectCount).toBe(1); - }); - + describe("7b. Approving a judge writes no queue", () => { /** - * A judges row belongs to one hackathon and isJudge authorizes against it, - * so a queue built across editions can never be opened — the projects in - * it are simply never scored, with nothing anywhere reporting a problem. - * assignToHackathon already refuses this; this path did not. - */ - it("refuses to build a queue for a judge from another hackathon", async () => { - wireInit("Sports", pool, HACK_B); - - await expect( - adminCaller().judge.initializeQueue({ - judgeId: JUDGE_ID, - hackathonId: HACK_A, - shuffle: false, - }), - ).rejects.toThrow(/different hackathon/i); - - expect(mockDelete).not.toHaveBeenCalled(); - }); - }); - - // ===================================================================== - describe("7b. Approving a judge builds their queue", () => { - /** - * judge.register always writes an assignment row, and assignToHackathon - * refuses anyone who already has one — so the documented recruitment path - * produced an active judge whose portal said "All Done" having judged - * nothing. The only remedy was assignJudgesToProjects, which deletes and - * rebuilds every queue in the event. + * Approval used to build the judge's queue, because nothing else would. + * With the shared pool an active judge draws tables as they ask for them, + * so approval writes no judge_queue rows at all and only reports how many + * projects the judge can be sent to. */ const wireApproval = (opts: { assignment?: Record | undefined; - queueCount: number; projects?: Record[]; }) => { mockFindFirst.mockImplementation((table: string) => { @@ -988,27 +652,20 @@ describe("Judge edge cases", () => { : { id: "asn_1", judgeId: JUDGE_ID, hackathonId: HACK_A, track: null }; return undefined; }); - mockFindMany.mockImplementation((table: string) => - table === "judgingProjects" ? (opts.projects ?? []) : [], - ); mockUpdate.mockReturnValue([ { userId: "judge_user", hackathonId: HACK_A }, ]); - mockSelect.mockReturnValue([{ count: opts.queueCount }]); + // The judge-row lock, then the two reads behind loadPool: every + // assignment in the hackathon, and its judgeable projects. + mockSelect + .mockReturnValueOnce([{ id: JUDGE_ID }]) + .mockReturnValueOnce([]) + .mockReturnValueOnce(opts.projects ?? []); }; - const project = (id: string, tableNumber: number) => ({ - id, - tracks: null, - challenges: null, - isCreateX: false, - tableNumber, - }); - - it("builds a queue when an approved judge has none", async () => { + it("approves a judge without writing any judge_queue rows", async () => { wireApproval({ - queueCount: 0, - projects: [project(PROJECT_A, 1), project(PROJECT_B, 2)], + projects: [poolRow(PROJECT_A, 1), poolRow(PROJECT_B, 2)], }); const res = await adminCaller().judge.setActive({ @@ -1016,32 +673,16 @@ describe("Judge edge cases", () => { isActive: true, }); + expect(res).toMatchObject({ success: true, isActive: true }); + // The pool the judge will draw from, not a queue written for them. expect(res.queuedProjects).toBe(2); - expect(insertedRows().map((r) => r.projectId)).toEqual([ - PROJECT_A, - PROJECT_B, - ]); - }); - - /** - * A judge suspended mid-event and reinstated has to come back to the queue - * they were part-way through. Rebuilding would forget what they already - * scored and reorder everything. - */ - it("leaves an existing queue alone", async () => { - wireApproval({ - queueCount: 5, - projects: [project(PROJECT_A, 1)], - }); - - const res = await adminCaller().judge.setActive({ - judgeId: JUDGE_ID, - isActive: true, - }); - - expect(res.queuedProjects).toBeNull(); - expect(mockInsert).not.toHaveBeenCalled(); - expect(mockDelete).not.toHaveBeenCalled(); + expect(insertsInto(judgeQueue)).toHaveLength(0); + // Nor is any existing visit marked done or rewritten: a judge suspended + // mid-event and reinstated keeps what they have scored. + const touchedQueue = mockUpdate.mock.calls.some( + (call) => call[1]?.[0] === judgeQueue, + ); + expect(touchedQueue).toBe(false); }); /** @@ -1051,10 +692,7 @@ describe("Judge edge cases", () => { * built one, giving the judge every project twice. */ it("locks the judge row before deciding whether to build", async () => { - wireApproval({ - queueCount: 0, - projects: [project(PROJECT_A, 1)], - }); + wireApproval({ projects: [poolRow(PROJECT_A, 1)] }); await adminCaller().judge.setActive({ judgeId: JUDGE_ID, @@ -1070,7 +708,7 @@ describe("Judge edge cases", () => { }); it("builds nothing for a judge with no assignment, and nothing on suspend", async () => { - wireApproval({ assignment: undefined, queueCount: 0 }); + wireApproval({ assignment: undefined }); const approved = await adminCaller().judge.setActive({ judgeId: JUDGE_ID, @@ -1296,7 +934,16 @@ describe("Judge edge cases", () => { project: { id: PROJECT_A, hackathonId: HACK_A }, // The judge legitimately holds this project, so the vote turns only on // whether they are still a judge. - queue: [{ id: QUEUE_A, judgeId: JUDGE_ID, projectId: PROJECT_A }], + queue: [ + { + id: QUEUE_A, + judgeId: JUDGE_ID, + projectId: PROJECT_A, + isCompleted: false, + startedAt: recent(), + arrivedAt: recent(), + }, + ], }); await judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }); @@ -1396,112 +1043,22 @@ describe("Judge edge cases", () => { }); /** - * Queues are a snapshot of the project list. A project promoted afterwards - * used to sit in nobody's queue, receive zero votes, and then be dropped - * from the standings entirely by the zero-vote rule — an amber banner was - * the only sign. Appending is the only safe fix mid-judging; rebuilding - * reorders every queue that is already in progress. + * Judges draw tables from the shared pool, so a project promoted late is + * judgeable the moment its row exists. Promotion has no queues to append + * to and must not write any. */ - it("appends late projects to the queues that already exist", async () => { - mockFindFirst.mockImplementation((table: string) => { - if (table === "admins") return ADMIN_ROW; - if (table === "hackathons") return { tracks: ["AI"], challenges: null }; - return undefined; - }); - mockFindMany.mockImplementation((table: string) => { - if (table === "hackathonProjects") return [submission("s1")]; - // Two judges already hold queues; one is further along than the other. - if (table === "judgeQueue") - return [ - { judgeId: "j1", projectId: "old_1", order: 1 }, - { judgeId: "j1", projectId: "old_2", order: 2 }, - { judgeId: "j2", projectId: "old_1", order: 1 }, - ]; - if (table === "judgeAssignments") - return [ - { judgeId: "j1", track: "AI", judge: { isActive: true } }, - { judgeId: "j2", track: "AI", judge: { isActive: true } }, - ]; - return []; - }); - mockInsert.mockReturnValue([ - { - id: "jp_new", - tracks: ["AI"], - challenges: null, - isCreateX: false, - tableNumber: 8, - }, - ]); - mockSelect.mockReturnValue([{ count: 3 }]); - - const res = await adminCaller().judge.promoteSubmissions({ - hackathonId: HACK_A, - }); - - // Existing coverage is 3 rows over 2 projects → 1 judge per project. - expect(res.queueRowsAdded).toBe(1); - // The banner is gone: the organiser has nothing left to do. - expect(res.queuesNeedRebuild).toBe(false); - - const queued = insertedRows().filter((r) => r.projectId === "jp_new"); - expect(queued).toHaveLength(1); - // The judge with the shorter queue takes it, appended after their last - // slot — nothing already in either queue moves. - expect(queued[0]).toMatchObject({ judgeId: "j2", order: 2 }); - }); - - // Assignment has not run yet, so there is nothing to append to — building - // queues here would do it from an incomplete project list. - it("appends nothing when no queue exists yet", async () => { + it("writes no judge_queue rows and reports only the promotion counts", async () => { asAdmin(); mockFindMany.mockImplementation((table: string) => table === "hackathonProjects" ? [submission("s1")] : [], ); - mockSelect.mockReturnValue([{ count: 0 }]); const res = await adminCaller().judge.promoteSubmissions({ hackathonId: HACK_A, }); - expect(res.queueRowsAdded).toBe(0); - expect(res.queuesNeedRebuild).toBe(false); - }); - - // The one case an organiser still has to resolve: the new projects carry a - // track no active judge covers, so appending reaches nobody. - it("still warns when the append reaches no judge", async () => { - mockFindFirst.mockImplementation((table: string) => { - if (table === "admins") return ADMIN_ROW; - if (table === "hackathons") - return { tracks: ["AI", "Health"], challenges: null }; - return undefined; - }); - mockFindMany.mockImplementation((table: string) => { - if (table === "hackathonProjects") return [submission("s1")]; - if (table === "judgeQueue") - return [{ judgeId: "j1", projectId: "old_1", order: 1 }]; - if (table === "judgeAssignments") - return [{ judgeId: "j1", track: "Health", judge: { isActive: true } }]; - return []; - }); - mockInsert.mockReturnValue([ - { - id: "jp_new", - tracks: ["AI"], - challenges: null, - isCreateX: false, - tableNumber: 8, - }, - ]); - mockSelect.mockReturnValue([{ count: 1 }]); - - const res = await adminCaller().judge.promoteSubmissions({ - hackathonId: HACK_A, - }); - - expect(res.queueRowsAdded).toBe(0); - expect(res.queuesNeedRebuild).toBe(true); + expect(res).toEqual({ created: 1, alreadyPresent: 0, total: 1 }); + expect(insertsInto(judgeQueue)).toHaveLength(0); }); }); @@ -1574,7 +1131,16 @@ describe("Judge edge cases", () => { wireJudge({ project: { id: "p_real", hackathonId: HACK_A }, - queue: [{ id: QUEUE_A, judgeId: JUDGE_ID, projectId: PROJECT_A }], + queue: [ + { + id: QUEUE_A, + judgeId: JUDGE_ID, + projectId: PROJECT_A, + isCompleted: false, + startedAt: recent(), + arrivedAt: recent(), + }, + ], }); await judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }); @@ -1657,97 +1223,6 @@ describe("Judge edge cases", () => { }); }); - // ===================================================================== - describe("13. Two judges, one table", () => { - const BUSY = { - id: QUEUE_A, - projectId: PROJECT_A, - order: 1, - project: { id: PROJECT_A, name: "Busy Table", tableNumber: 1 }, - }; - const FREE = { - id: "queue_b", - projectId: PROJECT_B, - order: 2, - project: { id: PROJECT_B, name: "Free Table", tableNumber: 2 }, - }; - - /** First judgeQueue.findMany is this judge's queue, second is the claims. */ - const wireQueue = (queue: unknown[], claims: unknown[]) => { - wireJudge({}); - let calls = 0; - mockFindMany.mockImplementation((table: string) => { - if (table !== "judgeQueue") return []; - calls += 1; - return calls === 1 ? queue : claims; - }); - }; - - it("routes around a table another judge is already standing at", async () => { - wireQueue( - [BUSY, FREE], - [{ projectId: PROJECT_A, startedAt: new Date() }], - ); - - const res = await judgeCaller().judge.getNextTable({ - hackathonId: HACK_A, - }); - - // Sent to the free table, and nothing in the response hints that a table - // was passed over — the judge has nothing to decide or dismiss. - expect(res.project).toMatchObject({ tableNumber: 2 }); - expect(res.queueId).toBe("queue_b"); - expect(res.done).toBe(false); - // The skipped table is still owed, so it stays in the count. - expect(res.remaining).toBe(2); - }); - - it("hands over a table whose claim has gone stale", async () => { - // Nothing comes back from the claim query once the window has passed, - // so an abandoned table needs no admin to release it. - wireQueue([BUSY, FREE], []); - - const res = await judgeCaller().judge.getNextTable({ - hackathonId: HACK_A, - }); - - expect(res.project).toMatchObject({ tableNumber: 1 }); - expect(res.queueId).toBe(QUEUE_A); - }); - - it("serves a claimed table rather than stranding a judge with nothing to do", async () => { - const now = Date.now(); - wireQueue( - [BUSY, FREE], - [ - { projectId: PROJECT_A, startedAt: new Date(now - 9 * 60 * 1000) }, - { projectId: PROJECT_B, startedAt: new Date(now - 1 * 60 * 1000) }, - ], - ); - - const res = await judgeCaller().judge.getNextTable({ - hackathonId: HACK_A, - }); - - // Every table is busy, so the judge still gets one — the table claimed - // longest ago, being the one most likely to be free by the time they - // walk over. - expect(res.done).toBe(false); - expect(res.project).toMatchObject({ tableNumber: 1 }); - }); - - it("claims the table it hands out, so the next judge routes around it", async () => { - wireQueue([BUSY, FREE], []); - - await judgeCaller().judge.getNextTable({ hackathonId: HACK_A }); - - const claimWrite = mockUpdate.mock.calls.find( - (call) => call[2]?.[0]?.startedAt instanceof Date, - ); - expect(claimWrite).toBeDefined(); - }); - }); - // ===================================================================== describe("12. Freezing results", () => { const wireResults = (opts: { @@ -1935,7 +1410,7 @@ describe("Judge edge cases", () => { await expect( judgeCaller().judge.startByQrCode({ qrCode: QR }), - ).rejects.toThrow(/not in your queue/i); + ).rejects.toThrow(/not the table you were sent to/i); expect(mockUpdate).not.toHaveBeenCalled(); }); @@ -1967,7 +1442,8 @@ describe("Judge edge cases", () => { // Re-scanning out of uncertainty must not restart the clock, which would // otherwise let a long visit be quietly reset to zero. it("does not restart the clock on a second scan", async () => { - const arrivedAt = new Date("2026-08-06T10:00:00Z"); + // Inside the window: past the cutoff a second scan is refused instead. + const arrivedAt = recent(); wireScan({ project, slot: { id: QUEUE_A, judgeId: JUDGE_ID, arrivedAt, startedAt: arrivedAt }, @@ -1989,112 +1465,175 @@ describe("Judge edge cases", () => { describe("10. Live judge progress", () => { const MIN = 60 * 1000; - /** queueRows first, then voteRows — the order liveProgress selects them. */ - const wireFloor = (queueRows: unknown[], voteRows: unknown[]) => { + const judgeRow = (id: string, name: string) => ({ + id, + name, + email: `${name.toLowerCase()}@example.com`, + isActive: true, + }); + + /** + * liveProgress reads the judges and the projects through the query API, + * and the visits (joined to their projects) then the votes through + * select, in that order. + */ + const wireFloor = (opts: { + judges: unknown[]; + visits?: unknown[]; + votes?: unknown[]; + projects?: unknown[]; + }) => { mockFindFirst.mockImplementation((table: string) => table === "admins" ? ADMIN_ROW : undefined, ); + mockFindMany.mockImplementation((table: string) => { + if (table === "judges") return opts.judges; + if (table === "judgingProjects") return opts.projects ?? []; + return []; + }); mockSelect.mockReset(); mockSelect - .mockReturnValueOnce(queueRows) - .mockReturnValueOnce(voteRows) + .mockReturnValueOnce(opts.visits ?? []) + .mockReturnValueOnce(opts.votes ?? []) .mockReturnValue([]); }; - const slot = (over: Record = {}) => ({ + const visit = (over: Record = {}) => ({ judgeId: JUDGE_ID, - judgeName: "Ada", - judgeEmail: "ada@example.com", - isActive: true, + projectId: PROJECT_A, isCompleted: false, startedAt: null, - completedAt: null, - order: 1, + arrivedAt: null, tableNumber: 7, projectName: "Flood Mapper", ...over, }); - it("reports a judge who has a queue and has scored nothing", async () => { - wireFloor([slot()], []); + it("reports a judge who has not been sent to a table yet", async () => { + wireFloor({ + judges: [judgeRow(JUDGE_ID, "Ada")], + projects: [{ id: PROJECT_A }], + }); const res = await adminCaller().judge.liveProgress({ hackathonId: HACK_A, }); expect(res.judges[0]).toMatchObject({ + judgeId: JUDGE_ID, status: "not_started", - assigned: 1, - completed: 0, scored: 0, + voided: 0, + idleMinutes: null, + current: null, }); - expect(res.totals).toMatchObject({ assigned: 1, completed: 0, percent: 0 }); + expect(res.totals).toMatchObject({ judges: 1, scored: 0, voided: 0 }); }); it("shows which table a judge is standing at, and for how long", async () => { - wireFloor( - [slot({ startedAt: new Date(Date.now() - 8 * MIN) })], - [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 300 }], - ); + wireFloor({ + judges: [judgeRow(JUDGE_ID, "Ada")], + visits: [ + visit({ + startedAt: new Date(Date.now() - 3 * MIN), + arrivedAt: new Date(Date.now() - 2 * MIN), + }), + ], + projects: [{ id: PROJECT_A }], + }); const res = await adminCaller().judge.liveProgress({ hackathonId: HACK_A, }); - expect(res.judges[0]).toMatchObject({ status: "judging" }); + expect(res.judges[0]).toMatchObject({ status: "judging", idleMinutes: 0 }); + // Timed from the tap, not from hand-out: the walk is not judging. expect(res.judges[0]!.current).toMatchObject({ tableNumber: 7, - onItMinutes: 8, + projectName: "Flood Mapper", + phase: "judging", }); + expect(res.judges[0]!.current!.seconds).toBeGreaterThanOrEqual(120); + expect(res.judges[0]!.current!.seconds).toBeLessThan(130); }); - it("counts idle minutes from the last vote, not from the queue", async () => { - wireFloor( - [slot({ isCompleted: true, completedAt: new Date() }), slot({ order: 2 })], - [ + it("counts idle minutes from the last vote", async () => { + wireFloor({ + judges: [judgeRow(JUDGE_ID, "Ada")], + visits: [ + visit({ + isCompleted: true, + startedAt: new Date(Date.now() - 30 * MIN), + arrivedAt: new Date(Date.now() - 28 * MIN), + }), + ], + votes: [ { judgeId: JUDGE_ID, + projectId: PROJECT_A, votedAt: new Date(Date.now() - 25 * MIN), - durationSeconds: 240, + durationSeconds: 180, }, ], - ); + projects: [{ id: PROJECT_A }], + }); const res = await adminCaller().judge.liveProgress({ hackathonId: HACK_A, }); - expect(res.judges[0]).toMatchObject({ status: "between", idleMinutes: 25 }); + expect(res.judges[0]).toMatchObject({ + status: "between", + scored: 1, + voided: 0, + idleMinutes: 25, + }); }); - it("reports a finished judge as done, at 100 percent", async () => { - wireFloor( - [slot({ isCompleted: true, completedAt: new Date() })], - [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 200 }], - ); + it("counts how many projects nobody has seen, and the fewest looks any has had", async () => { + wireFloor({ + judges: [judgeRow(JUDGE_ID, "Ada"), judgeRow(OTHER_JUDGE_ID, "Grace")], + votes: [ + { judgeId: JUDGE_ID, projectId: PROJECT_A, votedAt: new Date(), durationSeconds: 200 }, + { judgeId: OTHER_JUDGE_ID, projectId: PROJECT_A, votedAt: new Date(), durationSeconds: 200 }, + { judgeId: JUDGE_ID, projectId: PROJECT_B, votedAt: new Date(), durationSeconds: 200 }, + ], + projects: [{ id: PROJECT_A }, { id: PROJECT_B }, { id: "project_c" }], + }); const res = await adminCaller().judge.liveProgress({ hackathonId: HACK_A, }); - expect(res.judges[0]).toMatchObject({ status: "done" }); - expect(res.totals.percent).toBe(100); + expect(res.coverage).toMatchObject({ + projects: 3, + unseen: 1, + minLooks: 0, + atTarget: 1, + }); + expect(res.totals.scored).toBe(3); }); - it("puts the judge who has not started above the one who has finished", async () => { - wireFloor( - [ - slot({ isCompleted: true, completedAt: new Date() }), - slot({ judgeId: "judge_b", judgeName: "Grace", order: 1 }), + it("puts the judge who has not started above the one who is judging", async () => { + wireFloor({ + judges: [judgeRow(JUDGE_ID, "Ada"), judgeRow(OTHER_JUDGE_ID, "Grace")], + visits: [ + visit({ + startedAt: new Date(Date.now() - 2 * MIN), + arrivedAt: new Date(Date.now() - 1 * MIN), + }), ], - [{ judgeId: JUDGE_ID, votedAt: new Date(), durationSeconds: 200 }], - ); + projects: [{ id: PROJECT_A }], + }); const res = await adminCaller().judge.liveProgress({ hackathonId: HACK_A, }); - expect(res.judges.map((j) => j.status)).toEqual(["not_started", "done"]); + expect(res.judges.map((j) => [j.judgeId, j.status])).toEqual([ + [OTHER_JUDGE_ID, "not_started"], + [JUDGE_ID, "judging"], + ]); }); it("is refused to somebody who is not staff", async () => { @@ -2130,7 +1669,12 @@ describe("Judge edge cases", () => { projectId: PROJECT_A, }); - expect(mockDelete).toHaveBeenCalledTimes(1); + // Exactly one delete, on judge_queue. The audit write behind the + // withdrawal may also prune audit_logs, which is not this. + const queueDeletes = mockDelete.mock.calls.filter( + (call) => call[1]?.[0] === judgeQueue, + ); + expect(queueDeletes).toHaveLength(1); }); it("touches no queue when the project was never promoted", async () => { @@ -2141,7 +1685,78 @@ describe("Judge edge cases", () => { projectId: PROJECT_A, }); - expect(mockDelete).not.toHaveBeenCalled(); + expect( + mockDelete.mock.calls.filter((call) => call[1]?.[0] === judgeQueue), + ).toHaveLength(0); + }); + }); + + // ===================================================================== + /** + * The 4:00 cutoff, counted from the tap or scan. A score that arrives after + * it (plus a few seconds' grace) would be a look nobody can vouch for, so + * the table goes back into the pool instead. + */ + describe("15. The scoring cutoff", () => { + const slotArrived = (arrivedAt: Date) => ({ + id: QUEUE_A, + judgeId: JUDGE_ID, + hackathonId: HACK_A, + projectId: PROJECT_A, + isCompleted: false, + startedAt: arrivedAt, + arrivedAt, + }); + + it("refuses a first score past the cutoff, but lets an existing one be revised", async () => { + wireJudge({ + project: { id: PROJECT_A, hackathonId: HACK_A }, + queue: [slotArrived(lapsed())], + }); + + await expect( + judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: expect.stringMatching(/time ran out/i), + }); + expect(mockInsert).not.toHaveBeenCalled(); + + // The same late slot, but the judge already scored inside the window: + // correcting that score is allowed while judging is open. + wireJudge({ + project: { id: PROJECT_A, hackathonId: HACK_A }, + queue: [slotArrived(lapsed())], + vote: { id: "vote_1" }, + }); + + await expect( + judgeCaller().judge.submitVote({ projectId: PROJECT_A, ...scores }), + ).resolves.toBeDefined(); + expect(insertsInto(judgeVotes)).toHaveLength(1); + }); + + it("voids a completion past the cutoff and moves the judge on", async () => { + wireJudge({ + project: { id: PROJECT_A, hackathonId: HACK_A }, + queue: [slotArrived(lapsed())], + }); + // Nothing left to hand out, so the judge is done once the slot closes. + wireDispatch({ pool: [] }); + + const res = await judgeCaller().judge.completeAndNext({ + queueId: QUEUE_A, + projectId: PROJECT_A, + ...scores, + }); + + expect(res).toMatchObject({ timedOut: true, done: true }); + expect(insertsInto(judgeVotes)).toHaveLength(0); + // The slot is closed, so the table goes back to the pool. + const closed = mockUpdate.mock.calls.some( + (call) => call[1]?.[0] === judgeQueue && call[2]?.[0]?.isCompleted === true, + ); + expect(closed).toBe(true); }); }); diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index 4edc8322..f2e569d4 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -14,30 +14,17 @@ import { } from "@query/db"; import { eq, and, asc, sql, inArray, isNull } from "drizzle-orm"; import { isAdmin, isSuperAdmin } from "../../middleware/procedures"; -import { recordAdminAction } from "../../middleware/audit"; import { CacheKeys, invalidatePortalContext } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; -import { shuffleArray, buildCoverageQueues } from "./helpers"; +import { isLive, loadPool } from "./dispatch"; -// Rows per queue INSERT. Well under the ~16k that Postgres's 65535-parameter -// ceiling allows at 4 bound parameters per row. -const QUEUE_INSERT_CHUNK = 5000; - -// One judge's whole queue, in order. Shared by initializeQueue and setActive -// so an approved judge cannot get a queue built by different rules. Replaces -// whatever queue existed — setActive only calls it when the queue is empty, -// since a judge part-way through must not be reshuffled mid-event. -async function rebuildQueueForJudge( +// Judges draw tables from a shared pool (dispatch.ts) instead of holding a +// list built in advance. Clears rows an earlier version built that the judge +// never started, which would otherwise read as visits, and returns how many +// projects the judge can be sent to. +async function poolSizeForJudge( db: DrizzleDB, - opts: { - judgeId: string; - hackathonId: string; - shuffle?: boolean; - // Leave completed slots and rebuild only the remainder. A completed slot - // cannot be reconstructed — skipProject marks one done without a vote — so - // deleting it sends the judge back to a table they already dealt with. - keepCompleted?: boolean; - }, + opts: { judgeId: string; hackathonId: string }, ) { await db .delete(judgeQueue) @@ -45,80 +32,19 @@ async function rebuildQueueForJudge( and( eq(judgeQueue.judgeId, opts.judgeId), eq(judgeQueue.hackathonId, opts.hackathonId), - opts.keepCompleted ? eq(judgeQueue.isCompleted, false) : undefined, + eq(judgeQueue.isCompleted, false), + isNull(judgeQueue.startedAt), + isNull(judgeQueue.arrivedAt), ), ); - - // Projects the judge already dealt with must not return, or they score the - // same table twice. - const kept = opts.keepCompleted - ? await db.query.judgeQueue.findMany({ - where: and( - eq(judgeQueue.judgeId, opts.judgeId), - eq(judgeQueue.hackathonId, opts.hackathonId), - ), - columns: { projectId: true, order: true }, - }) - : []; - - const keptProjectIds = new Set(kept.map((row) => row.projectId)); - const highestKeptOrder = kept.reduce( - (max, row) => Math.max(max, row.order), - 0, - ); - - // The assignment's track narrows the pool; without one the judge sees every - // project in the edition. - const assignment = await db.query.judgeAssignments.findFirst({ - where: and( - eq(judgeAssignments.judgeId, opts.judgeId), - eq(judgeAssignments.hackathonId, opts.hackathonId), - ), - }); - - const allProjects = await db.query.judgingProjects.findMany({ - // Withdrawn entries must not be queued to anyone. - where: and( - eq(judgingProjects.hackathonId, opts.hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - orderBy: [asc(judgingProjects.tableNumber)], - }); - - let projects = assignment?.track - ? allProjects.filter((p) => { - const inTracks = p.tracks?.includes(assignment.track!) ?? false; - const inChallenges = p.challenges?.includes(assignment.track!) ?? false; - const matchCreateX = - assignment.track!.toLowerCase() === "createx" && !!p.isCreateX; - return inTracks || inChallenges || matchCreateX; - }) - : allProjects; - - projects = projects.filter((p) => !keptProjectIds.has(p.id)); - - if (opts.shuffle) { - projects = shuffleArray(projects); - } - - if (projects.length > 0) { - await db.insert(judgeQueue).values( - projects.map((p, idx) => ({ - judgeId: opts.judgeId, - hackathonId: opts.hackathonId, - projectId: p.id, - order: highestKeptOrder + idx + 1, - })), - ); - } - - return projects.length; + const { pool } = await loadPool(db, opts.judgeId, opts.hackathonId); + return pool.length; } // The labels a judge's track may take, for one edition. Routing matches these // exactly, so anything else classifies the judge as sponsor/special and // filters their pool to zero with nothing on screen explaining why. "createX" -// is here because buildCoverageQueues routes it on the project flag. +// is here because dispatch routes it on the project flag. async function assertTrackExists( db: DrizzleDB, hackathonId: string, @@ -155,148 +81,6 @@ async function assertTrackExists( return match; } -/** Does this project fall inside a judge's track/challenge label? */ -const projectMatchesTrack = ( - project: { - tracks: string[] | null; - challenges: string[] | null; - isCreateX?: boolean | null; - }, - track: string | null, -) => { - if (!track) return true; - const inTracks = project.tracks?.includes(track) ?? false; - const inChallenges = project.challenges?.includes(track) ?? false; - const matchCreateX = - track.toLowerCase() === "createx" && !!project.isCreateX; - return inTracks || inChallenges || matchCreateX; -}; - -// Adds late-promoted projects to existing queues without touching a row -// already there. Re-running assignJudgesToProjects deletes and rebuilds every -// queue, which is unsafe once judging is live — so promotion appends instead. -// Coverage matches what existing queues already give a project, so late -// entries are judged about as often as everything else. -async function appendProjectsToExistingQueues( - db: DrizzleDB, - hackathonId: string, - freshProjects: { - id: string; - tracks: string[] | null; - challenges: string[] | null; - isCreateX: boolean | null; - tableNumber: number; - }[], -) { - if (freshProjects.length === 0) return 0; - - const existingQueue = await db.query.judgeQueue.findMany({ - where: eq(judgeQueue.hackathonId, hackathonId), - columns: { judgeId: true, projectId: true, order: true }, - }); - - // Nothing assigned yet, so there is no queue to append to — the organiser - // still has to run assignment once, and doing it here would build queues from - // an incomplete project list. - if (existingQueue.length === 0) return 0; - - const nextOrder = new Map(); - const queueLength = new Map(); - const coverage = new Map(); - - for (const row of existingQueue) { - nextOrder.set(row.judgeId, Math.max(nextOrder.get(row.judgeId) ?? 0, row.order)); - queueLength.set(row.judgeId, (queueLength.get(row.judgeId) ?? 0) + 1); - coverage.set(row.projectId, (coverage.get(row.projectId) ?? 0) + 1); - } - - // How many judges an already-queued project is seen by, on average. Rounded - // down but never below 1: one judge is the difference between being ranked - // and being invisible. - const target = Math.max( - 1, - Math.floor( - [...coverage.values()].reduce((sum, n) => sum + n, 0) / coverage.size, - ), - ); - - const hackathon = await db.query.hackathons.findFirst({ - where: eq(hackathons.id, hackathonId), - columns: { tracks: true }, - }); - - const assignments = await db.query.judgeAssignments.findMany({ - where: eq(judgeAssignments.hackathonId, hackathonId), - with: { judge: { columns: { isActive: true } } }, - }); - - // Only judges who already hold a queue: anyone else is inactive or was never - // assigned, and a queue nobody opens is coverage the event never receives. - const eligible = assignments.filter( - (a) => a.judge.isActive !== false && queueLength.has(a.judgeId), - ); - - if (eligible.length === 0) return 0; - - // Same fallback as assignJudgesToProjects: with no tracks configured, every - // judge label would read as a sponsor one and get everything. - const mainTracks = new Set( - hackathon?.tracks?.length - ? hackathon.tracks - : eligible.map((a) => a.track).filter((t): t is string => !!t), - ); - - const rows: { - judgeId: string; - hackathonId: string; - projectId: string; - order: number; - }[] = []; - - const ordered = [...freshProjects].sort( - (a, b) => a.tableNumber - b.tableNumber, - ); - - for (const project of ordered) { - const matching = eligible.filter((a) => - projectMatchesTrack(project, a.track ?? null), - ); - - // Sponsor/special judges take their whole pool, as the bulk assignment gives - // it to them. - const special = matching.filter( - (a) => a.track && !mainTracks.has(a.track), - ); - const main = matching - .filter((a) => !a.track || mainTracks.has(a.track)) - // Shortest queue first, so late projects land on the judges with the - // most room rather than piling onto whoever comes first alphabetically. - .sort( - (a, b) => - (queueLength.get(a.judgeId) ?? 0) - (queueLength.get(b.judgeId) ?? 0), - ) - .slice(0, target); - - for (const a of [...special, ...main]) { - const order = (nextOrder.get(a.judgeId) ?? 0) + 1; - nextOrder.set(a.judgeId, order); - queueLength.set(a.judgeId, (queueLength.get(a.judgeId) ?? 0) + 1); - rows.push({ - judgeId: a.judgeId, - hackathonId, - projectId: project.id, - order, - }); - } - } - - for (let i = 0; i < rows.length; i += QUEUE_INSERT_CHUNK) { - await db.insert(judgeQueue).values(rows.slice(i, i + QUEUE_INSERT_CHUNK)); - } - - return rows.length; -} - export const judgeAdminRouter = createTRPCRouter({ // Judges, optionally scoped to one hackathon. A judges row belongs to exactly // one edition and assignToHackathon refuses any judge from another, so an @@ -464,59 +248,8 @@ export const judgeAdminRouter = createTRPCRouter({ }) .returning(); - const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ - where: eq(hackathons.id, input.hackathonId), - columns: { tracks: true }, - }); - const mainTracks = new Set(hackathon?.tracks ?? []); - - const allProjects = await ( - ctx.db as DrizzleDB - ).query.judgingProjects.findMany({ - // Withdrawn entries must not be queued to anyone. - where: and( - eq(judgingProjects.hackathonId, input.hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - orderBy: [asc(judgingProjects.tableNumber)], - }); - - // Filter by track if assigned (fixes bug: previously assigned ALL projects regardless of track) - const isSpecial = track ? !mainTracks.has(track) : false; - const eligibleProjects = allProjects.filter((p) => - projectMatchesTrack(p, track), - ); - - // Special judges always get their full pool; main track judges get a shuffled subset - const assignedProjects = isSpecial - ? eligibleProjects - : shuffleArray(eligibleProjects); - - // initializeQueue may already have built this judge a queue, and - // judge_queue has no unique on (judge, project): appending the full pool - // would put every project in it twice. - const queued = await (ctx.db as DrizzleDB).query.judgeQueue.findMany({ - where: and( - eq(judgeQueue.judgeId, input.judgeId), - eq(judgeQueue.hackathonId, input.hackathonId), - ), - columns: { projectId: true, order: true }, - }); - const alreadyQueued = new Set(queued.map((row) => row.projectId)); - const lastOrder = queued.reduce((max, row) => Math.max(max, row.order), 0); - const toQueue = assignedProjects.filter((p) => !alreadyQueued.has(p.id)); - - if (toQueue.length > 0) { - await (ctx.db as DrizzleDB).insert(judgeQueue).values( - toQueue.map((p, idx) => ({ - judgeId: input.judgeId, - hackathonId: input.hackathonId, - projectId: p.id, - order: lastOrder + idx + 1, - })), - ); - } - + // No queue to build: the judge draws tables from the shared pool as soon + // as they are active. See dispatch.ts. return result[0]; }), @@ -549,8 +282,6 @@ export const judgeAdminRouter = createTRPCRouter({ created: 0, alreadyPresent: 0, total: 0, - queueRowsAdded: 0, - queuesNeedRebuild: false, }; } @@ -572,16 +303,8 @@ export const judgeAdminRouter = createTRPCRouter({ 0, ); - let promotedRows: { - id: string; - tracks: string[] | null; - challenges: string[] | null; - isCreateX: boolean | null; - tableNumber: number; - }[] = []; - if (fresh.length > 0) { - promotedRows = (await tx.insert(judgingProjects).values( + await tx.insert(judgingProjects).values( fresh.map((submission) => ({ hackathonId: input.hackathonId, sourceProjectId: submission.id, @@ -604,14 +327,7 @@ export const judgeAdminRouter = createTRPCRouter({ : null, isCreateX: submission.isCreateX ?? false, })), - ) - .returning({ - id: judgingProjects.id, - tracks: judgingProjects.tracks, - challenges: judgingProjects.challenges, - isCreateX: judgingProjects.isCreateX, - tableNumber: judgingProjects.tableNumber, - })) ?? []; + ); await tx .update(hackathonProjects) @@ -624,78 +340,14 @@ export const judgeAdminRouter = createTRPCRouter({ ); } - // Queues are built from a snapshot of the project list, so anything promoted - // after assignment has to be appended here. A rebuild would reorder every - // queue already in progress. - const queueRowsAdded = await appendProjectsToExistingQueues( - tx as unknown as DrizzleDB, - input.hackathonId, - promotedRows, - ); - - const [queued] = await tx - .select({ count: sql`count(*)::int` }) - .from(judgeQueue) - .where(eq(judgeQueue.hackathonId, input.hackathonId)); - return { created: fresh.length, alreadyPresent: submissions.length - fresh.length, total: submissions.length, - queueRowsAdded, - // Only still true if queues exist, something was promoted, and the append - // reached nobody — a track label no active judge covers. That one the - // organiser has to resolve. - queuesNeedRebuild: - fresh.length > 0 && (queued?.count ?? 0) > 0 && queueRowsAdded === 0, }; }); }), - initializeQueue: isAdmin - .input( - z.object({ - judgeId: z.string().uuid(), - hackathonId: z.string().uuid(), - shuffle: z.boolean().default(false), - }), - ) - .mutation(async ({ ctx, input }) => { - // A judges row belongs to one hackathon and isJudge authorizes against that, - // so a queue built for a judge from another edition can never be opened and - // its projects are silently never scored. assignToHackathon checks this. - const judge = await (ctx.db as DrizzleDB).query.judges.findFirst({ - where: eq(judges.id, input.judgeId), - columns: { hackathonId: true }, - }); - - if (!judge) { - throw new TRPCError({ code: "NOT_FOUND", message: "Judge not found" }); - } - - if (judge.hackathonId !== input.hackathonId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This judge belongs to a different hackathon", - }); - } - - // Completed slots survive. Re-initialising a judge mid-event otherwise wipes - // every table they scored or skipped, and a skip has no vote to rebuild from. - const projectCount = await rebuildQueueForJudge(ctx.db as DrizzleDB, { - judgeId: input.judgeId, - hackathonId: input.hackathonId, - shuffle: input.shuffle, - keepCompleted: true, - }); - - return { success: true, projectCount }; - }), - - // Approve or suspend a judge. judge.register creates the row inactive and - // judge.create refuses once it exists, so without this a self-registered - // judge can never be activated by any route. Any admin: it was super-admin - // only while every admin saw the button, so most organisers got an error. setActive: isAdmin .input( z.object({ @@ -774,19 +426,8 @@ export const judgeAdminRouter = createTRPCRouter({ if (!assignment) return null; - const [existingQueue] = await tx - .select({ count: sql`count(*)::int` }) - .from(judgeQueue) - .where( - and( - eq(judgeQueue.judgeId, input.judgeId), - eq(judgeQueue.hackathonId, updated.hackathonId), - ), - ); - - if ((existingQueue?.count ?? 0) > 0) return null; - - return await rebuildQueueForJudge(tx as unknown as DrizzleDB, { + // Nothing to build: an active judge draws from the shared pool. + return await poolSizeForJudge(tx as unknown as DrizzleDB, { judgeId: input.judgeId, hackathonId: updated.hackathonId, }); @@ -883,7 +524,7 @@ export const judgeAdminRouter = createTRPCRouter({ if ((completed?.count ?? 0) > 0 && !input.force) { throw new TRPCError({ code: "CONFLICT", - message: `This judge has already scored ${completed?.count} project(s). Changing their track keeps those scores but rebuilds the rest of their queue — confirm to continue.`, + message: `This judge has already scored ${completed?.count} project(s). Changing their track keeps those scores and sends them to the new track from their next table — confirm to continue.`, }); } @@ -892,13 +533,11 @@ export const judgeAdminRouter = createTRPCRouter({ .set({ track }) .where(eq(judgeAssignments.id, assignment.id)); - // Completed slots survive; the unjudged remainder is rebuilt against the new - // track. skipProject completes a slot without a vote, so those cannot be - // reconstructed from the votes table. - const projectCount = await rebuildQueueForJudge(ctx.db as DrizzleDB, { + // Scores already given stay. The judge's next tables come from the new + // track's pool, since dispatch reads the assignment on every hand-out. + const projectCount = await poolSizeForJudge(ctx.db as DrizzleDB, { judgeId: input.judgeId, hackathonId: input.hackathonId, - keepCompleted: true, }); return { @@ -909,7 +548,7 @@ export const judgeAdminRouter = createTRPCRouter({ keptCompleted: completed?.count ?? 0, message: (completed?.count ?? 0) > 0 - ? `Track updated. ${completed?.count} completed slot(s) kept; the rest of the queue was rebuilt.` + ? `Track updated. ${completed?.count} scored project(s) kept; their next tables come from the new track.` : null, }; }), @@ -949,326 +588,6 @@ export const judgeAdminRouter = createTRPCRouter({ // Bulk-assign projects to all judges for a hackathon. Main-track judges get // 3–9 randomly-selected projects; special-label judges (createX, sponsor // challenges) get ALL matching projects. - assignJudgesToProjects: isAdmin - .input( - z.object({ - hackathonId: z.string().uuid(), - minProjects: z.number().int().min(1).default(3), - maxProjects: z.number().int().min(1).default(9), - shuffle: z.boolean().default(true), - // False (default) randomizes special-label/sponsor pools; true keeps them in - // table order. - groupSpecial: z.boolean().default(false), - autoCalculate: z.boolean().default(true), - // Rebuild even though judging is live or work is done. Completed slots still - // carry over; this only waives the refusal, so the admin saw the count. - force: z.boolean().default(false), - }).refine((input) => input.maxProjects >= input.minProjects, { - // min 9 / max 3 silently capped every judge at 3. - message: "Maximum projects per judge must be at least the minimum.", - path: ["maxProjects"], - }), - ) - .mutation(async ({ ctx, input }) => { - const outcome = await (ctx.db as DrizzleDB).transaction(async (tx) => { - const hackathon = await tx.query.hackathons.findFirst({ - where: eq(hackathons.id, input.hackathonId), - }); - if (!hackathon) - throw new TRPCError({ - code: "NOT_FOUND", - message: "Hackathon not found", - }); - - // This deletes and rebuilds every queue in the hackathon. Run twice by - // accident — and the wizard drops you onto its button after an import — it - // restarts judging for everyone, mid-event. - if (hackathon.judgingActive && !input.force) { - throw new TRPCError({ - code: "CONFLICT", - message: - "Judging is live. Re-running assignment rebuilds every judge's queue. Stop judging first, or confirm to rebuild anyway.", - }); - } - - // Completed slots are not reconstructible from votes: skipProject completes a - // slot without writing one. judgingActive defaults false and organisers turn - // it off when judging closes, so that flag cannot be the only guard. - const completed = await tx.query.judgeQueue.findMany({ - where: and( - eq(judgeQueue.hackathonId, input.hackathonId), - eq(judgeQueue.isCompleted, true), - ), - }); - - if (completed.length > 0 && !input.force) { - throw new TRPCError({ - code: "CONFLICT", - message: `${completed.length} judging slot(s) are already complete. Rebuilding preserves them but reorders everything else — confirm to continue.`, - }); - } - - const allAssignments = await tx.query.judgeAssignments.findMany({ - where: eq(judgeAssignments.hackathonId, input.hackathonId), - with: { judge: true }, - }); - - // An applicant who was never activated can never open the portal (isJudge - // requires judges.isActive), so their queue is coverage never received. - const activeAssignments = allAssignments.filter( - (a) => a.judge.isActive !== false, - ); - - // With no tracks column configured every judge label reads as sponsor/special - // and bypasses the per-judge caps, so fall back to the judges' own tracks. - const MAIN_TRACKS = new Set( - hackathon.tracks?.length - ? hackathon.tracks - : activeAssignments - .map((a) => a.track) - .filter((t): t is string => !!t), - ); - - const allProjects = await tx.query.judgingProjects.findMany({ - where: and( - eq(judgingProjects.hackathonId, input.hackathonId), - isNull(judgingProjects.withdrawnAt), - ), - orderBy: [asc(judgingProjects.tableNumber)], - }); - - if (allProjects.length === 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No projects found for this hackathon", - }); - } - - if (activeAssignments.length === 0) { - // An empty roster and a roster awaiting approval need different remedies, so - // the message has to say which it is. - throw new TRPCError({ - code: "BAD_REQUEST", - message: - allAssignments.length === 0 - ? "No judges have been added to this hackathon yet" - : "No active judges found for this hackathon - every judge is still pending approval", - }); - } - - let minProjects = input.minProjects; - let maxProjects = input.maxProjects; - - if (input.autoCalculate) { - // Count active registered participants - const participantCountResult = await tx - .select({ count: sql`count(*)` }) - .from(hackathonParticipants) - .where( - and( - eq(hackathonParticipants.hackathonId, input.hackathonId), - sql`${hackathonParticipants.registrationStatus} != 'rejected'`, - ), - ); - const activeRegistrations = Number( - participantCountResult[0]?.count || 0, - ); - - const P = - allProjects.length || Math.ceil(activeRegistrations / 4) || 1; - const mainJudgesCount = - activeAssignments.filter( - (a) => !a.track || MAIN_TRACKS.has(a.track), - ).length || 40; // Default to 40 judges as requested - - // Required grades = max(3, floor((judges * window) / (projects * avg))). - // Window 3 hours, average 12 minutes (0.2h) per project. - const judgingWindowHours = 3; - const avgTimePerProjectHours = 0.2; - const calculatedQuota = Math.floor( - (mainJudgesCount * judgingWindowHours) / - (P * avgTimePerProjectHours), - ); - - const targetCoverage = Math.max(3, calculatedQuota); - const avgRequired = Math.ceil((P * targetCoverage) / mainJudgesCount); - - // Reduce cap: Judges should not have 20 projects. Setting a more reasonable cap of 12. - minProjects = Math.max(3, Math.min(avgRequired, 12)); - maxProjects = Math.max( - minProjects + 1, - Math.min(avgRequired + 1, 14), - ); - } - - // Clear existing queues - await tx - .delete(judgeQueue) - .where(eq(judgeQueue.hackathonId, input.hackathonId)); - - // Coverage-maximizing assignment: buildCoverageQueues guarantees every - // project is seen once before any gets a second judge. The old random slice - // could leave projects unseen. - const judgeList = activeAssignments.map((a) => ({ - judgeId: a.judgeId, - track: a.track ?? null, - })); - const projectList = allProjects.map((p) => ({ - id: p.id, - tracks: p.tracks ?? null, - challenges: p.challenges ?? null, - tableNumber: p.tableNumber, - isCreateX: p.isCreateX, - })); - - const queues = buildCoverageQueues( - judgeList, - projectList, - MAIN_TRACKS, - { - minProjects, - maxProjects, - shuffle: input.shuffle, - groupSpecial: input.groupSpecial, - }, - ); - - // One pass, skipping pairs a judge already finished. judge_queue has no - // unique on (judgeId, projectId), so without this the rebuild re-issues a - // completed pair as fresh and getNextTable sends the judge back. - const completedKeys = new Set( - completed.map((row) => `${row.judgeId}:${row.projectId}`), - ); - - const insertRows: { - judgeId: string; - hackathonId: string; - projectId: string; - order: number; - isCompleted?: boolean; - startedAt?: Date | null; - completedAt?: Date | null; - }[] = []; - for (const [judgeId, projectIds] of queues.entries()) { - let order = 0; - for (const projectId of projectIds) { - if (completedKeys.has(`${judgeId}:${projectId}`)) continue; - insertRows.push({ - judgeId, - hackathonId: input.hackathonId, - projectId, - order: ++order, - }); - } - } - - // Re-append finished work past the tail of each judge's new queue, so their - // history survives and nothing re-serves it. - const tailByJudge = new Map(); - for (const row of insertRows) { - tailByJudge.set( - row.judgeId, - Math.max(tailByJudge.get(row.judgeId) ?? 0, row.order), - ); - } - for (const row of completed) { - const next = (tailByJudge.get(row.judgeId) ?? 0) + 1; - tailByJudge.set(row.judgeId, next); - insertRows.push({ - judgeId: row.judgeId, - hackathonId: input.hackathonId, - projectId: row.projectId, - order: next, - isCompleted: true, - startedAt: row.startedAt, - completedAt: row.completedAt, - }); - } - - // Chunked: 4 bound parameters per row against Postgres's 65535 limit, about - // 16k rows. Sponsor-track pools are uncapped, so a few over a large project - // list aborts the whole assignment with an opaque driver error. - for (let i = 0; i < insertRows.length; i += QUEUE_INSERT_CHUNK) { - await tx - .insert(judgeQueue) - .values(insertRows.slice(i, i + QUEUE_INSERT_CHUNK)); - } - - // Coverage stats for admin feedback, counted over the merged set — over the - // generated rows alone a fully-judged project reads as uncovered and the - // admin re-runs assignment chasing it. - const projectCoverage = new Map(); - for (const row of insertRows) { - projectCoverage.set( - row.projectId, - (projectCoverage.get(row.projectId) ?? 0) + 1, - ); - } - const coverageValues = [...projectCoverage.values()]; - const uncoveredCount = allProjects.length - projectCoverage.size; - const avgCoverage = - coverageValues.length > 0 - ? Math.round( - (coverageValues.reduce((a, b) => a + b, 0) / - coverageValues.length) * - 10, - ) / 10 - : 0; - const minCoverage = - coverageValues.length > 0 ? Math.min(...coverageValues) : 0; - const maxCoverage = - coverageValues.length > 0 ? Math.max(...coverageValues) : 0; - - // Counted from the rows actually written, not `queues` — those still hold the - // completed pairs filtered out above. - const countByJudge = new Map(); - for (const row of insertRows) { - countByJudge.set(row.judgeId, (countByJudge.get(row.judgeId) ?? 0) + 1); - } - - const results = allAssignments.map((a) => ({ - judgeId: a.judgeId, - judgeName: a.judge.name, - track: a.track ?? null, - assignedCount: countByJudge.get(a.judgeId) ?? 0, - })); - - return { - success: true, - totalJudges: results.length, - totalInsertedRows: insertRows.length, - judgingWasActive: hackathon.judgingActive, - preservedCompleted: completed.length, - coverage: { - avg: avgCoverage, - min: minCoverage, - max: maxCoverage, - uncovered: uncoveredCount, - }, - assignments: results, - }; - }); - - // Deliberately AFTER the transaction commits, on ctx.db. recordAdminAction - // swallows its errors, which is right outside a transaction and dangerous - // inside one: a failed statement aborts the session, so COMMIT silently - // becomes ROLLBACK and every judge queue reverts behind a success response. - await recordAdminAction(ctx.db as DrizzleDB, { - userId: ctx.userId, - action: "judge.assignJudgesToProjects", - resourceId: input.hackathonId, - severity: input.force ? "critical" : "info", - metadata: { - forced: input.force, - judgingWasActive: outcome.judgingWasActive, - preservedCompleted: outcome.preservedCompleted, - totalRows: outcome.totalInsertedRows, - }, - }); - - return outcome; - }), - // The table cards to print, one per judgeable project, each carrying the code // a judge scans on arrival — the physical half of scan-to-start. tableCards: isAdmin @@ -1292,40 +611,37 @@ export const judgeAdminRouter = createTRPCRouter({ }), /** Per-judge scoring analytics for bias detection and performance review. */ - // Where every judge is, right now. All of it was already stored and nothing - // put it in one place, so finding a stalled judge meant walking the room. - // Uncached on purpose: read on a short poll, and stale is worse than nothing. + // The judging floor for the organiser: where every judge is, how evenly the + // projects have been seen, and whether the judges who actually turned up can + // finish in time. Nothing here assumes a head count; pace is measured. liveProgress: isAdmin .input(z.object({ hackathonId: z.string().uuid() })) .query(async ({ ctx, input }) => { const db = ctx.db as DrizzleDB; const now = new Date(); - const [queueRows, voteRows] = await Promise.all([ + const [judgeRows, visitRows, voteRows, projectRows] = await Promise.all([ + db.query.judges.findMany({ + where: eq(judges.hackathonId, input.hackathonId), + columns: { id: true, name: true, email: true, isActive: true }, + }), db .select({ judgeId: judgeQueue.judgeId, - judgeName: judges.name, - judgeEmail: judges.email, - isActive: judges.isActive, + projectId: judgeQueue.projectId, isCompleted: judgeQueue.isCompleted, startedAt: judgeQueue.startedAt, - completedAt: judgeQueue.completedAt, - order: judgeQueue.order, + arrivedAt: judgeQueue.arrivedAt, tableNumber: judgingProjects.tableNumber, projectName: judgingProjects.name, }) .from(judgeQueue) - .innerJoin(judges, eq(judges.id, judgeQueue.judgeId)) - .leftJoin( - judgingProjects, - eq(judgingProjects.id, judgeQueue.projectId), - ) - .where(eq(judgeQueue.hackathonId, input.hackathonId)) - .orderBy(asc(judgeQueue.order)), + .leftJoin(judgingProjects, eq(judgingProjects.id, judgeQueue.projectId)) + .where(eq(judgeQueue.hackathonId, input.hackathonId)), db .select({ judgeId: judgeVotes.judgeId, + projectId: judgeVotes.projectId, votedAt: judgeVotes.votedAt, durationSeconds: judgeVotes.durationSeconds, }) @@ -1337,103 +653,134 @@ export const judgeAdminRouter = createTRPCRouter({ eq(judgingProjects.hackathonId, input.hackathonId), ), ), + db.query.judgingProjects.findMany({ + where: and( + eq(judgingProjects.hackathonId, input.hackathonId), + isNull(judgingProjects.withdrawnAt), + ), + columns: { id: true }, + }), ]); - const votesByJudge = new Map(); - for (const v of voteRows) { - const list = votesByJudge.get(v.judgeId) ?? []; - list.push(v); - votesByJudge.set(v.judgeId, list); - } - - const byJudge = new Map(); - for (const row of queueRows) { - const list = byJudge.get(row.judgeId) ?? []; - list.push(row); - byJudge.set(row.judgeId, list); - } - + const voted = new Set(voteRows.map((v) => `${v.judgeId}:${v.projectId}`)); + const secondsSince = (d: Date) => + Math.max(0, Math.floor((now.getTime() - d.getTime()) / 1000)); const minutesSince = (d: Date | null) => - d ? Math.floor((now.getTime() - new Date(d).getTime()) / 60000) : null; - - const judgesOut = [...byJudge.entries()].map(([judgeId, rows]) => { - const first = rows[0]!; - const done = rows.filter((r) => r.isCompleted); - const votes = votesByJudge.get(judgeId) ?? []; - - // The project handed over but not yet scored. At most one: completeAndNext - // closes the previous row before claiming the next. - const current = rows.find((r) => !r.isCompleted && r.startedAt) ?? null; + d ? Math.floor(secondsSince(d) / 60) : null; - const lastVoteAt = votes.reduce((acc, v) => { - const at = v.votedAt ? new Date(v.votedAt) : null; - return at && (!acc || at > acc) ? at : acc; - }, null); + const judgesOut = judgeRows.map((judge) => { + const visits = visitRows.filter( + (r) => r.judgeId === judge.id && (r.startedAt || r.arrivedAt), + ); + const votes = voteRows.filter((v) => v.judgeId === judge.id); + const current = visits.find((r) => isLive(r, now)) ?? null; + + // Visits that closed without a score: the cutoff passed, or the judge + // passed on the table. + const voided = visits.filter( + (r) => + (r.isCompleted || !isLive(r, now)) && + !voted.has(`${judge.id}:${r.projectId}`), + ).length; + + const lastActivity = [ + ...votes.map((v) => v.votedAt), + ...visits.map((r) => r.arrivedAt ?? r.startedAt), + ].reduce( + (acc, d) => (d && (!acc || d > acc) ? d : acc), + null, + ); const durations = votes .map((v) => v.durationSeconds) - .filter((d): d is number => typeof d === "number" && d > 0); - - const medianSeconds = durations.length - ? [...durations].sort((a, b) => a - b)[ - Math.floor(durations.length / 2) - ]! - : null; - - const idleMinutes = minutesSince(lastVoteAt); + .filter((d): d is number => typeof d === "number" && d > 0) + .sort((a, b) => a - b); - const status = !first.isActive + const status = !judge.isActive ? ("suspended" as const) - : done.length === rows.length && rows.length > 0 - ? ("done" as const) - : current - ? ("judging" as const) - : votes.length === 0 - ? ("not_started" as const) - : ("between" as const); + : current + ? ("judging" as const) + : visits.length === 0 + ? ("not_started" as const) + : ("between" as const); return { - judgeId, - name: first.judgeName, - email: first.judgeEmail, + judgeId: judge.id, + name: judge.name, + email: judge.email, status, - assigned: rows.length, - completed: done.length, - remaining: rows.length - done.length, scored: votes.length, - medianSeconds, - idleMinutes, + voided, + medianSeconds: durations.length + ? durations[Math.floor(durations.length / 2)]! + : null, + idleMinutes: current ? 0 : minutesSince(lastActivity), + lastActiveAt: lastActivity, current: current ? { tableNumber: current.tableNumber, projectName: current.projectName, - onItMinutes: minutesSince(current.startedAt), + // Walking until the tap or scan, then the judging clock. + phase: current.arrivedAt ? ("judging" as const) : ("walking" as const), + seconds: secondsSince((current.arrivedAt ?? current.startedAt)!), } : null, }; }); // Worst first: a judge who has stopped is the reason to open this screen. - const rank = { not_started: 0, judging: 1, between: 2, done: 3, suspended: 4 }; + const rank = { not_started: 0, between: 1, judging: 2, suspended: 3 }; judgesOut.sort( (a, b) => rank[a.status] - rank[b.status] || (b.idleMinutes ?? 0) - (a.idleMinutes ?? 0), ); - const totalAssigned = queueRows.length; - const totalCompleted = queueRows.filter((r) => r.isCompleted).length; + // How evenly the projects have been seen. Two looks each is the floor + // the pool works towards before anyone gets a third. + const TARGET_LOOKS = 2; + const looks = new Map(projectRows.map((p) => [p.id, 0])); + for (const v of voteRows) { + if (looks.has(v.projectId)) looks.set(v.projectId, looks.get(v.projectId)! + 1); + } + const counts = [...looks.values()]; + const shortfall = counts.reduce((sum, n) => sum + Math.max(0, TARGET_LOOKS - n), 0); + + // Measured pace over the last 30 minutes, so the estimate tracks the + // judges who are actually on the floor, not the number who signed up. + const WINDOW_MIN = 30; + const windowStart = now.getTime() - WINDOW_MIN * 60_000; + const recentScores = voteRows.filter( + (v) => v.votedAt && v.votedAt.getTime() >= windowStart, + ).length; + const activeJudges = judgesOut.filter( + (j) => + j.status !== "suspended" && + j.lastActiveAt && + j.lastActiveAt.getTime() >= now.getTime() - 15 * 60_000, + ).length; + const perMinute = recentScores / WINDOW_MIN; return { - judges: judgesOut, + judges: judgesOut.map(({ lastActiveAt: _, ...j }) => j), + coverage: { + projects: counts.length, + unseen: counts.filter((n) => n === 0).length, + minLooks: counts.length ? Math.min(...counts) : 0, + atTarget: counts.filter((n) => n >= TARGET_LOOKS).length, + targetLooks: TARGET_LOOKS, + }, + pace: { + activeJudges, + scoredLast30Min: recentScores, + // Null until there is a pace to extrapolate from. + minutesToTarget: + shortfall === 0 ? 0 : perMinute > 0 ? Math.ceil(shortfall / perMinute) : null, + }, totals: { judges: judgesOut.length, - assigned: totalAssigned, - completed: totalCompleted, - percent: - totalAssigned === 0 - ? 0 - : Math.round((totalCompleted / totalAssigned) * 100), + scored: voteRows.length, + voided: judgesOut.reduce((sum, j) => sum + j.voided, 0), }, }; }), diff --git a/packages/api/src/routers/judge/dispatch.db.test.ts b/packages/api/src/routers/judge/dispatch.db.test.ts new file mode 100644 index 00000000..b2f9cf82 --- /dev/null +++ b/packages/api/src/routers/judge/dispatch.db.test.ts @@ -0,0 +1,295 @@ +/** + * The judging pool against a real Postgres, at Hacklytics scale. + * + * Mocks cannot show what matters here: that the dispatch lock keeps two + * judges off one table under real concurrency, and that coverage evens out + * over hundreds of projects whatever the head count. Runs only when + * JUDGING_TEST_DATABASE_URL points at a disposable database with the schema + * pushed (drizzle-kit push). Every test seeds its own hackathon. + * + * docker run -d --name judging-test -e POSTGRES_PASSWORD=pw -p 5498:5432 postgres:18 + * DATABASE_URL=postgresql://postgres:pw@localhost:5498/postgres pnpm --filter @query/db exec drizzle-kit push + * JUDGING_TEST_DATABASE_URL=postgresql://postgres:pw@localhost:5498/postgres pnpm --filter @query/api test dispatch.db + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import * as schema from "@query/db"; +import type { DrizzleDB } from "@query/db"; +import { + JUDGE_HARD_LIMIT_SECONDS, + JUDGE_WALK_LIMIT_SECONDS, + dispatchNext, +} from "./dispatch"; + +const URL = process.env.JUDGING_TEST_DATABASE_URL; + +const { + users, + hackathons, + judges, + judgeAssignments, + judgingProjects, + judgeQueue, + judgeVotes, +} = schema; + +let db: DrizzleDB; + +const at = (base: Date, seconds: number) => new Date(base.getTime() + seconds * 1000); + +async function seed(opts: { + projects: number; + judges: number; + tracks?: string[]; + projectTracks?: (i: number) => { tracks?: string[]; challenges?: string[] }; + judgeTrack?: (i: number) => string | null; +}) { + const hackathonId = randomUUID(); + await db.insert(hackathons).values({ + id: hackathonId, + name: `pool-test-${hackathonId.slice(0, 8)}`, + startDate: new Date(), + endDate: new Date(), + judgingActive: true, + tracks: opts.tracks ?? [], + } as typeof hackathons.$inferInsert); + + const projectIds: string[] = []; + const projectRows = Array.from({ length: opts.projects }, (_, i) => { + const id = randomUUID(); + projectIds.push(id); + const t = opts.projectTracks?.(i) ?? {}; + return { + id, + hackathonId, + name: `Project ${i + 1}`, + tableNumber: i + 1, + tracks: t.tracks ?? null, + challenges: t.challenges ?? null, + } as typeof judgingProjects.$inferInsert; + }); + for (let i = 0; i < projectRows.length; i += 200) { + await db.insert(judgingProjects).values(projectRows.slice(i, i + 200)); + } + + const judgeIds: string[] = []; + for (let i = 0; i < opts.judges; i++) { + const userId = randomUUID(); + const judgeId = randomUUID(); + judgeIds.push(judgeId); + await db.insert(users).values({ + id: userId, + email: `judge-${userId}@test.local`, + } as typeof users.$inferInsert); + await db.insert(judges).values({ + id: judgeId, + userId, + hackathonId, + isActive: true, + } as typeof judges.$inferInsert); + await db.insert(judgeAssignments).values({ + judgeId, + hackathonId, + track: opts.judgeTrack?.(i) ?? null, + } as typeof judgeAssignments.$inferInsert); + } + + return { hackathonId, projectIds, judgeIds }; +} + +const dispatch = (judgeId: string, hackathonId: string, now: Date) => + db.transaction((tx) => dispatchNext(tx as unknown as DrizzleDB, judgeId, hackathonId, now)); + +/** What completeAndNext does for a score inside the window. */ +async function score(judgeId: string, queueId: string, projectId: string, now: Date) { + await db.insert(judgeVotes).values({ + judgeId, + projectId, + score: 30, + scoreCreativity: 6, + scoreImpact: 6, + scoreScope: 6, + scoreClarity: 6, + scoreSoundness: 6, + }); + await db + .update(judgeQueue) + .set({ isCompleted: true, completedAt: now }) + .where(eq(judgeQueue.id, queueId)); +} + +async function arrive(queueId: string, now: Date) { + await db.update(judgeQueue).set({ arrivedAt: now }).where(eq(judgeQueue.id, queueId)); +} + +async function looksPerProject(projectIds: string[]) { + const rows = await db + .select({ projectId: judgeVotes.projectId, n: sql`count(*)::int` }) + .from(judgeVotes) + .where(inArray(judgeVotes.projectId, projectIds)) + .groupBy(judgeVotes.projectId); + const map = new Map(projectIds.map((id) => [id, 0])); + for (const r of rows) map.set(r.projectId, r.n); + return [...map.values()]; +} + +describe.skipIf(!URL)("judging pool on Postgres", () => { + beforeAll(() => { + db = drizzle({ connection: { connectionString: URL!, max: 50 }, schema }) as unknown as DrizzleDB; + }); + afterAll(async () => { + await (db as unknown as { $client: { end: () => Promise } } | undefined)?.$client.end(); + }); + + it("never sends two judges to the same table when 60 ask at once", async () => { + const { hackathonId, judgeIds } = await seed({ projects: 500, judges: 60 }); + const now = new Date(); + + const handed = await Promise.all(judgeIds.map((j) => dispatch(j, hackathonId, now))); + + const projects = handed.map((h) => (h.done ? null : h.project.id)); + expect(projects.every((p) => p !== null)).toBe(true); + expect(new Set(projects).size).toBe(60); + }, 30_000); + + it("gives every one of 500 projects two looks before any gets a third, with judges joining late", async () => { + const { hackathonId, projectIds, judgeIds } = await seed({ projects: 500, judges: 40 }); + const start = new Date("2027-02-28T13:00:00Z"); + + // 25 judges start; the other 15 turn up 40 minutes in. Each look is a + // minute's walk and two and a half minutes at the table. + let clock = 0; + const onFloor = new Set(judgeIds.slice(0, 25)); + let scored = 0; + while (scored < 1000) { + if (clock >= 40 * 60) judgeIds.slice(25).forEach((j) => onFloor.add(j)); + for (const judgeId of onFloor) { + if (scored >= 1000) break; + const t = at(start, clock); + const next = await dispatch(judgeId, hackathonId, t); + if (next.done) continue; + await arrive(next.queueId, at(t, 60)); + await score(judgeId, next.queueId, next.project.id, at(t, 210)); + scored++; + } + clock += 210; + } + + const looks = await looksPerProject(projectIds); + expect(Math.min(...looks)).toBe(2); + expect(Math.max(...looks)).toBe(2); + }, 120_000); + + it("hands a no-show's table to someone else once the walk limit passes", async () => { + const { hackathonId, judgeIds } = await seed({ projects: 2, judges: 2 }); + const [absent, present] = judgeIds as [string, string]; + const t0 = new Date("2027-02-28T13:00:00Z"); + + const held = await dispatch(absent, hackathonId, t0); + if (held.done) throw new Error("expected a table"); + + // While the absent judge could still be walking over, their table is held. + const other = await dispatch(present, hackathonId, at(t0, 30)); + if (other.done) throw new Error("expected a table"); + expect(other.project.id).not.toBe(held.project.id); + await arrive(other.queueId, at(t0, 60)); + await score(present, other.queueId, other.project.id, at(t0, 200)); + + // They never arrive. Past the walk limit the table is free again. + const late = await dispatch(present, hackathonId, at(t0, JUDGE_WALK_LIMIT_SECONDS + 1)); + if (late.done) throw new Error("expected the abandoned table"); + expect(late.project.id).toBe(held.project.id); + }); + + it("voids a look that passes the hard cutoff and puts the project back in the pool", async () => { + const { hackathonId, judgeIds } = await seed({ projects: 2, judges: 2 }); + const [slow, other] = judgeIds as [string, string]; + const t0 = new Date("2027-02-28T13:00:00Z"); + + const mine = await dispatch(slow, hackathonId, t0); + if (mine.done) throw new Error("expected a table"); + await arrive(mine.queueId, at(t0, 30)); + + // Past 4:00 (plus grace) without a score: asking again closes the visit + // and moves the judge on rather than handing back the same table. + const after = at(t0, 30 + JUDGE_HARD_LIMIT_SECONDS + 60); + const next = await dispatch(slow, hackathonId, after); + if (next.done) throw new Error("expected a second table"); + expect(next.project.id).not.toBe(mine.project.id); + + const [closed] = await db + .select() + .from(judgeQueue) + .where(eq(judgeQueue.id, mine.queueId)); + expect(closed?.isCompleted).toBe(true); + + // The voided project has no score, so it is the least-covered for others. + const theirs = await dispatch(other, hackathonId, after); + if (theirs.done) throw new Error("expected a table"); + expect(theirs.project.id).toBe(mine.project.id); + }); + + it("returns the same live table on a repeat request instead of claiming a second", async () => { + const { hackathonId, judgeIds } = await seed({ projects: 5, judges: 1 }); + const t0 = new Date("2027-02-28T13:00:00Z"); + const a = await dispatch(judgeIds[0]!, hackathonId, t0); + const b = await dispatch(judgeIds[0]!, hackathonId, at(t0, 20)); + if (a.done || b.done) throw new Error("expected tables"); + expect(b.queueId).toBe(a.queueId); + const rows = await db + .select() + .from(judgeQueue) + .where(and(eq(judgeQueue.hackathonId, hackathonId), eq(judgeQueue.judgeId, judgeIds[0]!))); + expect(rows).toHaveLength(1); + }); + + it("clears a list built in advance by the old assignment instead of treating it as visits", async () => { + const { hackathonId, projectIds, judgeIds } = await seed({ projects: 3, judges: 1 }); + await db.insert(judgeQueue).values( + projectIds.map((projectId, i) => ({ + judgeId: judgeIds[0]!, + hackathonId, + projectId, + order: i + 1, + })), + ); + const next = await dispatch(judgeIds[0]!, hackathonId, new Date()); + expect(next.done).toBe(false); + const rows = await db + .select() + .from(judgeQueue) + .where(eq(judgeQueue.judgeId, judgeIds[0]!)); + expect(rows).toHaveLength(1); + }); + + it("keeps a sponsor judge to their challenge and counts their looks separately", async () => { + const { hackathonId, projectIds, judgeIds } = await seed({ + projects: 6, + judges: 2, + tracks: ["Finance"], + // Projects 1-3 entered the sponsor challenge. + projectTracks: (i) => ({ tracks: ["Finance"], challenges: i < 3 ? ["Sponsor"] : [] }), + judgeTrack: (i) => (i === 0 ? "Sponsor" : "Finance"), + }); + const [sponsor, main] = judgeIds as [string, string]; + const t0 = new Date("2027-02-28T13:00:00Z"); + + const seen: string[] = []; + for (let i = 0; i < 4; i++) { + const next = await dispatch(sponsor, hackathonId, at(t0, i * 300)); + if (next.done) break; + seen.push(next.project.id); + await arrive(next.queueId, at(t0, i * 300 + 10)); + await score(sponsor, next.queueId, next.project.id, at(t0, i * 300 + 100)); + } + expect(new Set(seen)).toEqual(new Set(projectIds.slice(0, 3))); + + // The sponsor's looks do not count as main coverage: the main judge still + // starts on a project nobody on the main panel has seen, which may be one + // of the three the sponsor already scored. + const next = await dispatch(main, hackathonId, at(t0, 2000)); + expect(next.done).toBe(false); + }); +}); diff --git a/packages/api/src/routers/judge/dispatch.test.ts b/packages/api/src/routers/judge/dispatch.test.ts new file mode 100644 index 00000000..b029c2ac --- /dev/null +++ b/packages/api/src/routers/judge/dispatch.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + JUDGE_HARD_LIMIT_SECONDS, + JUDGE_SUBMIT_GRACE_SECONDS, + JUDGE_WALK_LIMIT_SECONDS, + isLive, + isPastCutoff, + pickNext, + projectMatchesTrack, +} from "./dispatch"; +import type { Candidate } from "./dispatch"; + +const t0 = new Date("2027-02-28T13:00:00Z"); +const at = (seconds: number) => new Date(t0.getTime() + seconds * 1000); +const first = () => 0; + +const c = (id: string, tableNumber: number, coverage = 0, claimedAt: number | null = null): Candidate => ({ + id, + tableNumber, + coverage, + claimedAt, +}); + +describe("pickNext", () => { + it("returns nothing when there is nothing left", () => { + expect(pickNext([], null)).toBeNull(); + }); + + it("takes the least-covered project", () => { + expect(pickNext([c("a", 1, 2), c("b", 2, 0), c("c", 3, 1)], null, first)?.id).toBe("b"); + }); + + it("breaks a coverage tie by the table nearest the judge's last one", () => { + const pick = pickNext([c("far", 90, 1), c("near", 12, 1), c("mid", 40, 1)], 10, first); + expect(pick?.id).toBe("near"); + }); + + it("never trades coverage for distance", () => { + expect(pickNext([c("next-door", 11, 2), c("across", 200, 1)], 10, first)?.id).toBe("across"); + }); + + it("passes over a table someone is at while a free one exists", () => { + expect(pickNext([c("taken", 1, 0, at(0).getTime()), c("free", 2, 3)], null, first)?.id).toBe("free"); + }); + + it("sends the judge to the longest-held table when every one is taken", () => { + const pick = pickNext( + [c("recent", 1, 0, at(100).getTime()), c("oldest", 2, 0, at(0).getTime())], + null, + first, + ); + expect(pick?.id).toBe("oldest"); + }); + + it("chooses among equals at random", () => { + const tier = [c("a", 5, 0), c("b", 15, 0)]; + expect(pickNext(tier, 10, () => 0)?.id).toBe("a"); + expect(pickNext(tier, 10, () => 1)?.id).toBe("b"); + }); +}); + +describe("isLive", () => { + const walking = { startedAt: t0, arrivedAt: null, isCompleted: false }; + const atTable = { startedAt: t0, arrivedAt: at(60), isCompleted: false }; + + it("holds a table for the walk limit after hand-out", () => { + expect(isLive(walking, at(JUDGE_WALK_LIMIT_SECONDS))).toBe(true); + expect(isLive(walking, at(JUDGE_WALK_LIMIT_SECONDS + 1))).toBe(false); + }); + + it("holds it through the hard limit and grace after arrival", () => { + const end = 60 + JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS; + expect(isLive(atTable, at(end))).toBe(true); + expect(isLive(atTable, at(end + 1))).toBe(false); + }); + + it("measures from arrival, not hand-out, once the judge has tapped in", () => { + // Past the walk limit from hand-out, still well inside the judging window. + expect(isLive(atTable, at(JUDGE_WALK_LIMIT_SECONDS + 30))).toBe(true); + }); + + it("is never live once completed, or when never handed out", () => { + expect(isLive({ ...atTable, isCompleted: true }, at(61))).toBe(false); + expect(isLive({ startedAt: null, arrivedAt: null, isCompleted: false }, t0)).toBe(false); + }); +}); + +describe("isPastCutoff", () => { + it("allows a score at 4:00 and within the grace after it", () => { + expect(isPastCutoff(t0, at(JUDGE_HARD_LIMIT_SECONDS))).toBe(false); + expect(isPastCutoff(t0, at(JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS))).toBe(false); + }); + + it("refuses one after the grace", () => { + expect(isPastCutoff(t0, at(JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS + 1))).toBe(true); + }); +}); + +describe("projectMatchesTrack", () => { + const p = { tracks: ["Finance"], challenges: ["Sponsor"], isCreateX: true }; + it("matches every project when the judge has no track", () => { + expect(projectMatchesTrack(p, null)).toBe(true); + }); + it("matches by track, challenge and the createX flag", () => { + expect(projectMatchesTrack(p, "Finance")).toBe(true); + expect(projectMatchesTrack(p, "Sponsor")).toBe(true); + expect(projectMatchesTrack(p, "createX")).toBe(true); + expect(projectMatchesTrack(p, "Health")).toBe(false); + }); +}); diff --git a/packages/api/src/routers/judge/dispatch.ts b/packages/api/src/routers/judge/dispatch.ts new file mode 100644 index 00000000..1eab7c70 --- /dev/null +++ b/packages/api/src/routers/judge/dispatch.ts @@ -0,0 +1,371 @@ +import { randomInt } from "node:crypto"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { + hackathons, + judgeAssignments, + judgeQueue, + judgeVotes, + judgingProjects, +} from "@query/db"; +import type { DrizzleDB } from "@query/db"; + +/** + * Judging runs from a shared pool, not per-judge lists built in advance. + * + * A judge asks for a table; the server hands them the eligible project with + * the fewest looks so far that they have not had and nobody else is at. A + * judge_queue row is written at that moment, so the table holds visits, not + * plans. Judges who arrive late start contributing immediately, judges who + * never show strand nothing, and coverage evens out on its own whatever the + * head count turns out to be. + */ + +/** Target time at a table; the judge page turns amber here. */ +export const JUDGE_TARGET_SECONDS = 180; +/** Hard cutoff from the tap or scan. No score by then and the look is void. */ +export const JUDGE_HARD_LIMIT_SECONDS = 240; +/** How long a handed-out table stays held while the judge walks to it. */ +export const JUDGE_WALK_LIMIT_SECONDS = 240; +/** Slack on the cutoff for a submit that left the phone just before 4:00. */ +export const JUDGE_SUBMIT_GRACE_SECONDS = 15; + +type Slot = { + startedAt: Date | null; + arrivedAt: Date | null; + isCompleted: boolean; +}; + +/** + * Whether a visit still holds its table. Walking: until the walk limit after + * hand-out. At the table: until the hard limit (plus grace) after arrival. + */ +export function isLive(slot: Slot, now: Date): boolean { + if (slot.isCompleted) return false; + const t = now.getTime(); + if (slot.arrivedAt) { + return ( + t <= + slot.arrivedAt.getTime() + + (JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS) * 1000 + ); + } + if (slot.startedAt) { + return t <= slot.startedAt.getTime() + JUDGE_WALK_LIMIT_SECONDS * 1000; + } + return false; +} + +/** Past the hard cutoff, with grace: a score now arrives too late to count. */ +export function isPastCutoff(arrivedAt: Date, now: Date): boolean { + return ( + now.getTime() > + arrivedAt.getTime() + + (JUDGE_HARD_LIMIT_SECONDS + JUDGE_SUBMIT_GRACE_SECONDS) * 1000 + ); +} + +export type Candidate = { + id: string; + tableNumber: number; + /** Scores plus live claims from judges in the same group. */ + coverage: number; + /** Most recent live claim by another judge, if any (ms epoch). */ + claimedAt: number | null; +}; + +/** + * The next project for one judge, from the projects they are eligible for and + * have not had. Fewest looks first; among equals the nearest table to where + * the judge just was, since at three minutes a table the walk is real time; + * then random. A table someone is already at is passed over while any other + * exists; when every remaining one is taken, the one claimed longest ago, + * because an idle judge is worse than two at a table. + */ +export function pickNext( + candidates: Candidate[], + lastTable: number | null, + random: (n: number) => number = (n) => randomInt(0, n), +): Candidate | null { + if (candidates.length === 0) return null; + + const free = candidates.filter((c) => c.claimedAt === null); + if (free.length === 0) { + return [...candidates].sort( + (a, b) => (a.claimedAt ?? 0) - (b.claimedAt ?? 0), + )[0]!; + } + + const fewest = Math.min(...free.map((c) => c.coverage)); + const tier = free.filter((c) => c.coverage === fewest); + + if (lastTable === null) return tier[random(tier.length)]!; + + const distance = (c: Candidate) => Math.abs(c.tableNumber - lastTable); + const nearest = Math.min(...tier.map(distance)); + const closest = tier.filter((c) => distance(c) === nearest); + return closest[random(closest.length)]!; +} + +/** Does this project fall inside a judge's track/challenge label? */ +export const projectMatchesTrack = ( + project: { + tracks: string[] | null; + challenges: string[] | null; + isCreateX?: boolean | null; + }, + track: string | null, +) => { + if (!track) return true; + const inTracks = project.tracks?.includes(track) ?? false; + const inChallenges = project.challenges?.includes(track) ?? false; + const matchCreateX = track.toLowerCase() === "createx" && !!project.isCreateX; + return inTracks || inChallenges || matchCreateX; +}; + +/** + * Serialises dispatch within one hackathon for the rest of the transaction. + * Choosing a table reads coverage and claims and then writes one; without + * this two judges asking at once could both be sent to the same table. + * Dispatch is a few dozen milliseconds and runs a handful of times a minute, + * so a single lock per hackathon costs nothing. + */ +export async function lockDispatch(tx: DrizzleDB, hackathonId: string) { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${`judging:${hackathonId}`}))`, + ); +} + +/** + * Coverage is counted within a judge's group. Sponsor/special-label judges + * cover their own pool for their own prize; looks from them do not stand in + * for the main panel and vice versa. With no main tracks configured every + * label counts as main, the same fallback the old assignment used. + */ +function groupOf(track: string | null, mainTracks: Set) { + return track && !mainTracks.has(track) ? `special:${track}` : "main"; +} + +export type DispatchResult = + | { done: true } + | { + done: false; + queueId: string; + project: typeof judgingProjects.$inferSelect; + startedAt: Date | null; + arrivedAt: Date | null; + }; + +/** + * Projects this judge is eligible for (only the columns routing needs), and + * the group they judge in. Queries run one after another: inside a + * transaction they share one connection, which cannot run two at once. + */ +export async function loadPool( + db: DrizzleDB, + judgeId: string, + hackathonId: string, +) { + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, hackathonId), + columns: { tracks: true }, + }); + const assignments = await db + .select({ judgeId: judgeAssignments.judgeId, track: judgeAssignments.track }) + .from(judgeAssignments) + .where(eq(judgeAssignments.hackathonId, hackathonId)); + const projects = await db + .select({ + id: judgingProjects.id, + tableNumber: judgingProjects.tableNumber, + tracks: judgingProjects.tracks, + challenges: judgingProjects.challenges, + isCreateX: judgingProjects.isCreateX, + }) + .from(judgingProjects) + .where( + and( + eq(judgingProjects.hackathonId, hackathonId), + isNull(judgingProjects.withdrawnAt), + ), + ); + + const mainTracks = new Set( + hackathon?.tracks?.length + ? hackathon.tracks + : assignments.map((a) => a.track).filter((t): t is string => !!t), + ); + const trackByJudge = new Map(assignments.map((a) => [a.judgeId, a.track])); + const myTrack = trackByJudge.get(judgeId) ?? null; + const groupByJudge = (id: string) => + groupOf(trackByJudge.get(id) ?? null, mainTracks); + + return { + pool: projects.filter((p) => projectMatchesTrack(p, myTrack)), + myGroup: groupOf(myTrack, mainTracks), + groupByJudge, + }; +} + +const fullProject = async (tx: DrizzleDB, id: string) => + (await tx.query.judgingProjects.findFirst({ + where: eq(judgingProjects.id, id), + }))!; + +/** + * Hands the judge their next table. Must run inside a transaction. The pool + * (projects and tracks, which barely move during judging) is read first; then + * the hackathon's dispatch lock is taken for the part that has to be atomic: + * void the judge's own visits that ran out, return a visit that is still live + * (so a reload or a second tab gets the same table), and otherwise pick and + * claim a new one from current coverage and claims. + */ +export async function dispatchNext( + tx: DrizzleDB, + judgeId: string, + hackathonId: string, + now: Date = new Date(), +): Promise { + const { pool, myGroup, groupByJudge } = await loadPool(tx, judgeId, hackathonId); + + await lockDispatch(tx, hackathonId); + + const mine = await tx + .select({ + id: judgeQueue.id, + projectId: judgeQueue.projectId, + isCompleted: judgeQueue.isCompleted, + startedAt: judgeQueue.startedAt, + arrivedAt: judgeQueue.arrivedAt, + completedAt: judgeQueue.completedAt, + }) + .from(judgeQueue) + .where( + and( + eq(judgeQueue.judgeId, judgeId), + eq(judgeQueue.hackathonId, hackathonId), + ), + ); + + // A visit that ran out without a score is over; the project goes back to + // the pool for someone else, and not to this judge again. A row that was + // never handed out at all is a list an earlier version built in advance: + // it is not a visit, so it goes rather than blocking that project. + const unopened = mine.filter( + (r) => !r.isCompleted && !r.startedAt && !r.arrivedAt, + ); + const lapsed = mine.filter( + (r) => !r.isCompleted && (r.startedAt || r.arrivedAt) && !isLive(r, now), + ); + if (unopened.length > 0) { + await tx.delete(judgeQueue).where( + inArray( + judgeQueue.id, + unopened.map((r) => r.id), + ), + ); + } + if (lapsed.length > 0) { + await tx + .update(judgeQueue) + .set({ isCompleted: true, completedAt: now }) + .where( + inArray( + judgeQueue.id, + lapsed.map((r) => r.id), + ), + ); + } + const visits = mine.filter((r) => !unopened.includes(r)); + + const poolById = new Map(pool.map((p) => [p.id, p])); + const live = visits.find((r) => isLive(r, now)); + // A live visit to a project withdrawn since is dropped like any other. + if (live && poolById.has(live.projectId)) { + return { + done: false, + queueId: live.id, + project: await fullProject(tx, live.projectId), + startedAt: live.startedAt, + arrivedAt: live.arrivedAt, + }; + } + + const seen = new Set(visits.map((r) => r.projectId)); + const unseen = pool.filter((p) => !seen.has(p.id)); + if (unseen.length === 0) return { done: true }; + + // Scores and open visits for the whole hackathon: a few thousand rows at + // most, and one indexed read each rather than a 500-id parameter list. + const votes = await tx + .select({ judgeId: judgeVotes.judgeId, projectId: judgeVotes.projectId }) + .from(judgeVotes) + .innerJoin( + judgingProjects, + and( + eq(judgingProjects.id, judgeVotes.projectId), + eq(judgingProjects.hackathonId, hackathonId), + ), + ); + const open = await tx + .select({ + judgeId: judgeQueue.judgeId, + projectId: judgeQueue.projectId, + startedAt: judgeQueue.startedAt, + arrivedAt: judgeQueue.arrivedAt, + isCompleted: judgeQueue.isCompleted, + }) + .from(judgeQueue) + .where( + and( + eq(judgeQueue.hackathonId, hackathonId), + eq(judgeQueue.isCompleted, false), + ), + ); + + const coverage = new Map(); + const bump = (projectId: string) => + coverage.set(projectId, (coverage.get(projectId) ?? 0) + 1); + for (const v of votes) if (groupByJudge(v.judgeId) === myGroup) bump(v.projectId); + + const claimedAt = new Map(); + for (const row of open) { + if (row.judgeId === judgeId || !isLive(row, now)) continue; + if (groupByJudge(row.judgeId) === myGroup) bump(row.projectId); + const at = (row.arrivedAt ?? row.startedAt)!.getTime(); + claimedAt.set(row.projectId, Math.max(claimedAt.get(row.projectId) ?? 0, at)); + } + + const stamp = (r: (typeof visits)[number]) => + (r.completedAt ?? r.arrivedAt ?? r.startedAt ?? new Date(0)).getTime(); + const lastVisit = [...visits].sort((a, b) => stamp(b) - stamp(a))[0]; + + const choice = pickNext( + unseen.map((p) => ({ + id: p.id, + tableNumber: p.tableNumber, + coverage: coverage.get(p.id) ?? 0, + claimedAt: claimedAt.get(p.id) ?? null, + })), + lastVisit ? (poolById.get(lastVisit.projectId)?.tableNumber ?? null) : null, + ); + if (!choice) return { done: true }; + + const [row] = await tx + .insert(judgeQueue) + .values({ + judgeId, + hackathonId, + projectId: choice.id, + order: visits.length + 1, + startedAt: now, + }) + .returning({ id: judgeQueue.id }); + + return { + done: false, + queueId: row!.id, + project: await fullProject(tx, choice.id), + startedAt: now, + arrivedAt: null, + }; +} diff --git a/packages/api/src/routers/judge/helpers-edge.test.ts b/packages/api/src/routers/judge/helpers-edge.test.ts index 4de86a05..3b4753d4 100644 --- a/packages/api/src/routers/judge/helpers-edge.test.ts +++ b/packages/api/src/routers/judge/helpers-edge.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect } from "vitest"; -import { shuffleArray, zNormalize, buildCoverageQueues } from "./helpers"; +import { zNormalize } from "./helpers"; /** - * The maths and the assignment that decide who wins. + * The maths that decides who wins. * * These are the invariants a result has to satisfy to be defensible: a judge's * own ordering must survive normalization, two judges who rank the same order - * must agree after it whatever their personal harshness, and no judge may be - * handed the same project twice. + * must agree after it whatever their personal harshness. */ const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length; @@ -97,214 +96,3 @@ describe("zNormalize — properties", () => { expect(normalized[0]).toBeCloseTo(normalized[1]!, 10); }); }); - -describe("shuffleArray — invariants", () => { - it("returns the same multiset", () => { - const input = [1, 2, 3, 4, 5, 6, 7, 8]; - const output = shuffleArray(input); - expect([...output].sort()).toEqual([...input].sort()); - }); - - it("does not mutate its input", () => { - const input = [1, 2, 3]; - shuffleArray(input); - expect(input).toEqual([1, 2, 3]); - }); - - it("handles an empty array", () => { - expect(shuffleArray([])).toEqual([]); - }); - - it("handles a single element", () => { - expect(shuffleArray(["only"])).toEqual(["only"]); - }); - - it("keeps duplicates rather than deduping", () => { - expect(shuffleArray([1, 1, 2]).sort()).toEqual([1, 1, 2]); - }); - - it("eventually produces a different order for a large array", () => { - const input = Array.from({ length: 30 }, (_, i) => i); - const orders = new Set( - Array.from({ length: 10 }, () => shuffleArray(input).join(",")), - ); - // A CSPRNG shuffle of 30 items repeating 10 times is effectively - // impossible; a stuck shuffle would show up here. - expect(orders.size).toBeGreaterThan(1); - }); -}); - -describe("buildCoverageQueues — invariants", () => { - const mainTracks = new Set(["AI", "Web"]); - const project = (id: string, track: string) => ({ - id, - tracks: [track], - challenges: null, - tableNumber: Number(id.slice(1)), - }); - - const projects = [ - project("p1", "AI"), - project("p2", "AI"), - project("p3", "Web"), - project("p4", "Web"), - ]; - - const opts = { - minProjects: 2, - maxProjects: 4, - shuffle: false, - groupSpecial: true, - }; - - it("never hands one judge the same project twice", () => { - const judges = [ - { judgeId: "j1", track: null }, - { judgeId: "j2", track: null }, - { judgeId: "j3", track: "AI" }, - ]; - - const queues = buildCoverageQueues(judges, projects, mainTracks, opts); - - for (const [judgeId, queue] of queues) { - expect(new Set(queue).size, `duplicate in ${judgeId}'s queue`).toBe( - queue.length, - ); - } - }); - - it("covers every project at least once when there is capacity", () => { - const judges = [ - { judgeId: "j1", track: null }, - { judgeId: "j2", track: null }, - ]; - - const queues = buildCoverageQueues(judges, projects, mainTracks, opts); - - const covered = new Set([...queues.values()].flat()); - for (const p of projects) expect(covered.has(p.id)).toBe(true); - }); - - it("respects maxProjects for every judge", () => { - const judges = [ - { judgeId: "j1", track: null }, - { judgeId: "j2", track: null }, - ]; - - const queues = buildCoverageQueues(judges, projects, mainTracks, { - ...opts, - maxProjects: 2, - }); - - for (const queue of queues.values()) { - expect(queue.length).toBeLessThanOrEqual(2); - } - }); - - it("gives every judge a queue entry even when nothing matches", () => { - const judges = [{ judgeId: "j-ghost", track: "Robotics" }]; - - const queues = buildCoverageQueues(judges, projects, mainTracks, opts); - - expect(queues.has("j-ghost")).toBe(true); - expect(queues.get("j-ghost")).toEqual([]); - }); - - it("returns an empty map for no judges", () => { - expect(buildCoverageQueues([], projects, mainTracks, opts).size).toBe(0); - }); - - it("gives empty queues when there are no projects", () => { - const judges = [{ judgeId: "j1", track: null }]; - - const queues = buildCoverageQueues(judges, [], mainTracks, opts); - - expect(queues.get("j1")).toEqual([]); - }); - - it("restricts a track judge to that track's projects", () => { - const judges = [{ judgeId: "j-ai", track: "AI" }]; - - const queues = buildCoverageQueues(judges, projects, mainTracks, opts); - - for (const id of queues.get("j-ai")!) { - expect(["p1", "p2"]).toContain(id); - } - }); - - /** A track outside mainTracks is a special award: that judge sees everything - * eligible, not a coverage-balanced slice. */ - it("gives a special-track judge their whole eligible pool", () => { - const withSpecial = [ - ...projects, - { - id: "p5", - tracks: ["Sustainability"], - challenges: null, - tableNumber: 5, - }, - ]; - const judges = [{ judgeId: "j-special", track: "Sustainability" }]; - - const queues = buildCoverageQueues(judges, withSpecial, mainTracks, opts); - - expect(queues.get("j-special")).toEqual(["p5"]); - }); - - it("matches a project by its challenges as well as its tracks", () => { - const challengeProject = { - id: "p6", - tracks: [], - challenges: ["Sustainability"], - tableNumber: 6, - }; - const judges = [{ judgeId: "j-special", track: "Sustainability" }]; - - const queues = buildCoverageQueues( - judges, - [challengeProject], - mainTracks, - opts, - ); - - expect(queues.get("j-special")).toEqual(["p6"]); - }); - - it("only assigns ids that exist in the project list", () => { - const judges = [ - { judgeId: "j1", track: null }, - { judgeId: "j2", track: "Web" }, - ]; - const ids = new Set(projects.map((p) => p.id)); - - const queues = buildCoverageQueues(judges, projects, mainTracks, opts); - - for (const queue of queues.values()) { - for (const id of queue) expect(ids.has(id)).toBe(true); - } - }); - - it("spreads a second pass evenly rather than piling onto one project", () => { - // Four judges, four projects, room for everyone to see everything. - const judges = [ - { judgeId: "j1", track: null }, - { judgeId: "j2", track: null }, - { judgeId: "j3", track: null }, - { judgeId: "j4", track: null }, - ]; - - const queues = buildCoverageQueues(judges, projects, mainTracks, { - ...opts, - minProjects: 4, - maxProjects: 4, - }); - - const counts = new Map(); - for (const queue of queues.values()) { - for (const id of queue) counts.set(id, (counts.get(id) ?? 0) + 1); - } - - const seen = [...counts.values()]; - expect(Math.max(...seen) - Math.min(...seen)).toBeLessThanOrEqual(1); - }); -}); diff --git a/packages/api/src/routers/judge/helpers.test.ts b/packages/api/src/routers/judge/helpers.test.ts index 03a7b1f7..96598aba 100644 --- a/packages/api/src/routers/judge/helpers.test.ts +++ b/packages/api/src/routers/judge/helpers.test.ts @@ -1,19 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - shuffleArray, - zNormalize, - buildCoverageQueues, -} from "./helpers"; - -describe("shuffleArray", () => { - it("preserves length and elements", () => { - const input = [1, 2, 3, 4, 5]; - const output = shuffleArray(input); - expect(output).toHaveLength(input.length); - expect(output.sort()).toEqual(input.sort()); - expect(input).toEqual([1, 2, 3, 4, 5]); - }); -}); +import { zNormalize } from "./helpers"; describe("zNormalize", () => { it("returns global mean when fewer than two scores", () => { @@ -32,52 +18,3 @@ describe("zNormalize", () => { expect(normalized[2]).toBeGreaterThan(5); }); }); - -describe("buildCoverageQueues", () => { - const projects = [ - { id: "p1", tracks: ["AI"], challenges: null, tableNumber: 1 }, - { id: "p2", tracks: ["AI"], challenges: null, tableNumber: 2 }, - { id: "p3", tracks: ["Web"], challenges: null, tableNumber: 3 }, - ]; - - it("assigns every project before doubling coverage", () => { - const judges = [ - { judgeId: "j1", track: "AI" }, - { judgeId: "j2", track: "AI" }, - ]; - const queues = buildCoverageQueues(judges, projects, new Set(["AI", "Web"]), { - minProjects: 1, - maxProjects: 3, - shuffle: false, - groupSpecial: true, - }); - - const assigned = new Set(); - for (const q of queues.values()) q.forEach((id) => assigned.add(id)); - expect(assigned.has("p1")).toBe(true); - expect(assigned.has("p2")).toBe(true); - }); - - it("respects maxProjects per judge", () => { - const judges = [{ judgeId: "j1", track: null }]; - const queues = buildCoverageQueues(judges, projects, new Set(["AI", "Web"]), { - minProjects: 1, - maxProjects: 2, - shuffle: false, - groupSpecial: true, - }); - expect(queues.get("j1")!.length).toBeLessThanOrEqual(2); - }); - - it("filters special-track judges to their track pool", () => { - const judges = [{ judgeId: "j-web", track: "Web" }]; - const queues = buildCoverageQueues(judges, projects, new Set(["AI", "Web"]), { - minProjects: 1, - maxProjects: 5, - shuffle: false, - groupSpecial: true, - }); - const queue = queues.get("j-web")!; - expect(queue.every((id) => id === "p3")).toBe(true); - }); -}); diff --git a/packages/api/src/routers/judge/helpers.ts b/packages/api/src/routers/judge/helpers.ts index a752f664..2319cfd6 100644 --- a/packages/api/src/routers/judge/helpers.ts +++ b/packages/api/src/routers/judge/helpers.ts @@ -1,18 +1,4 @@ -import { randomInt } from "node:crypto"; - -// Shared judge scoring / queue utilities -export function shuffleArray(array: T[]): T[] { - const result = [...array]; - for (let i = result.length - 1; i > 0; i--) { - // CSPRNG so judging order cannot be predicted by a team that knows when their - // project was queued. - const j = randomInt(0, i + 1); - const temp = result[i] as T; - result[i] = result[j] as T; - result[j] = temp; - } - return result; -} +// Shared judge scoring utilities // Z-score normalizes a judge's scores against their own mean and stddev, // removing per-judge harshness before aggregation. Null under 2 data points. @@ -29,106 +15,3 @@ export function zNormalize( if (std === 0) return scores.map(() => globalMean); // judge gave same score to everything return scores.map((v) => globalMean + ((v - mean) / std) * globalStd); } - -// Coverage-maximizing round-robin: every project is seen at least -// `minCoverage` times before any gets an extra judge. Respects tracks. -export function buildCoverageQueues( - judgeAssignmentsList: { judgeId: string; track: string | null }[], - projects: { - id: string; - tracks: string[] | null; - challenges: string[] | null; - tableNumber: number; - }[], - mainTracks: Set, - opts: { - minProjects: number; - maxProjects: number; - shuffle: boolean; - groupSpecial: boolean; - }, -): Map { - const queues = new Map(); - for (const a of judgeAssignmentsList) queues.set(a.judgeId, []); - - // Coverage counter: projectId -> how many judges are assigned - const coverage = new Map(projects.map((p) => [p.id, 0])); - - // For each judge, determine their eligible project pool - const eligiblePool = new Map(); - for (const a of judgeAssignmentsList) { - const track = a.track; - const isSpecial = track ? !mainTracks.has(track) : false; - const pool = track - ? projects.filter((p) => { - const inTracks = p.tracks?.includes(track) ?? false; - const inChallenges = p.challenges?.includes(track) ?? false; - const matchCreateX = - track.toLowerCase() === "createx" && - !!(p as { isCreateX?: boolean }).isCreateX; - return inTracks || inChallenges || matchCreateX; - }) - : projects; - - if (isSpecial) { - // Special judges always get their full pool - const ordered = opts.groupSpecial ? pool : shuffleArray(pool); - queues.set( - a.judgeId, - ordered.map((p) => p.id), - ); - for (const p of ordered) - coverage.set(p.id, (coverage.get(p.id) ?? 0) + 1); - eligiblePool.set(a.judgeId, []); - } else { - eligiblePool.set(a.judgeId, opts.shuffle ? shuffleArray(pool) : pool); - } - } - - // Round-robin fill: prioritize under-covered projects - const mainJudges = judgeAssignmentsList.filter( - (a) => !a.track || mainTracks.has(a.track), - ); - - let anyChange = true; - while (anyChange) { - anyChange = false; - for (const a of mainJudges) { - const queue = queues.get(a.judgeId)!; - if (queue.length >= opts.maxProjects) continue; - const pool = eligiblePool.get(a.judgeId)!; - - // Find the next project with the lowest coverage that isn't already in this queue - const assigned = new Set(queue); - const candidate = pool - .filter((p) => !assigned.has(p.id)) - .sort( - (a, b) => (coverage.get(a.id) ?? 0) - (coverage.get(b.id) ?? 0), - )[0]; - - if (!candidate) continue; - if ( - queue.length >= opts.minProjects && - (coverage.get(candidate.id) ?? 0) > 0 - ) - continue; - - queue.push(candidate.id); - coverage.set(candidate.id, (coverage.get(candidate.id) ?? 0) + 1); - anyChange = true; - } - } - - // Shift/rotate the generated queues to stagger sequences and reduce judge bias - for (let i = 0; i < judgeAssignmentsList.length; i++) { - const judgeId = judgeAssignmentsList[i].judgeId; - const q = queues.get(judgeId); - if (q && q.length > 1) { - const offset = i % q.length; - const rotated = [...q.slice(offset), ...q.slice(0, offset)]; - queues.set(judgeId, rotated); - } - } - - return queues; -} \ No newline at end of file diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index c7229631..b0afda0a 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -9,17 +9,19 @@ import { judgeQueue, hackathons, } from "@query/db"; -import { eq, ne, gt, and, asc, inArray, sql , isNull } from "drizzle-orm"; +import { eq, and, asc, inArray, sql, isNull } from "drizzle-orm"; import { CacheKeys } from "../../middleware/cache"; import { isAdmin, isJudge } from "../../middleware/procedures"; import { resolveHackathonId } from "../../services/portal-context"; +import { + dispatchNext, + isLive, + isPastCutoff, + loadPool, + lockDispatch, +} from "./dispatch"; import type { DrizzleDB } from "@query/db"; -// How long a judge holds a table without refreshing the claim. getNextTable -// re-stamps on every poll, so a judge with the portal open keeps their table; -// one who closes the tab stops refreshing and it frees itself. A timeout -// rather than a lock somebody has to release. -const JUDGE_CLAIM_MINUTES = 10; export const judgePortalRouter = createTRPCRouter({ isJudge: protectedProcedure @@ -148,14 +150,16 @@ export const judgePortalRouter = createTRPCRouter({ })); }), - // Starts the clock by scanning the table's QR. Scoring time is measured from - // here, not from when the queue handed the table over: walking across a - // ballroom is not judging. It also confirms the judge is at the right table. - // Idempotent, so scanning twice cannot restart the clock. + // Starts the clock at the table: the tap on its NFC tag or the scan of its + // card. Scoring time runs from here, not from hand-out, since walking across + // a ballroom is not judging, and the hard cutoff counts from here too. It + // also confirms the judge is at the table they were sent to. Idempotent: + // tapping twice cannot restart the clock. startByQrCode: isJudge .input(z.object({ qrCode: z.string().uuid("Invalid table code") })) .mutation(async ({ ctx, input }) => { const db = ctx.db as DrizzleDB; + const now = new Date(); const project = await db.query.judgingProjects.findFirst({ where: eq(judgingProjects.qrCode, input.qrCode), @@ -168,122 +172,115 @@ export const judgePortalRouter = createTRPCRouter({ }); } - // Must be in THIS judge's queue. Scanning somebody else's table would start a - // clock on work that is not theirs and let them score an unrouted project. - const slot = await db.query.judgeQueue.findFirst({ - where: and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.projectId, project.id), - eq(judgeQueue.isCompleted, false), - ), - }); + return await db.transaction(async (rawTx) => { + const tx = rawTx as unknown as DrizzleDB; + await lockDispatch(tx, project.hackathonId); - if (!slot) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `Table ${project.tableNumber} is not in your queue. Check the table number against your next assignment.`, - }); - } - - if (!slot.arrivedAt) { - await db - .update(judgeQueue) - .set({ - arrivedAt: new Date(), - // Claim it too: a judge who walked up out of order has still taken the table, - // and another judge should route around them. - startedAt: slot.startedAt ?? new Date(), - }) - .where(eq(judgeQueue.id, slot.id)); - } - - return { - project, - queueId: slot.id, - alreadyStarted: !!slot.arrivedAt, - }; - }), - - getNextTable: isJudge - .input(z.object({ hackathonId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - try { - const db = ctx.db as DrizzleDB; - - const queue = await db.query.judgeQueue.findMany({ + const slot = await tx.query.judgeQueue.findFirst({ where: and( eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.hackathonId, input.hackathonId), - eq(judgeQueue.isCompleted, false), + eq(judgeQueue.projectId, project.id), ), - with: { - project: true, - }, - orderBy: [asc(judgeQueue.order)], }); - if (queue.length === 0) { - return { done: true, project: null, remaining: 0 }; + // Only the table the pool sent this judge to. Starting any other would + // let judges pick their favourites and skew coverage. + if (!slot) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Table ${project.tableNumber} is not the table you were sent to. Check the number on your screen.`, + }); } - // Tables another judge is standing at right now. Sending a second judge to a - // team already presenting helps nobody, so a claimed table is passed over and - // comes back on a later pass, with nothing for the judge to act on. - const claimedSince = new Date( - Date.now() - JUDGE_CLAIM_MINUTES * 60 * 1000, - ); - const claims = await db.query.judgeQueue.findMany({ - where: and( - inArray( - judgeQueue.projectId, - queue.map((row) => row.projectId), + if (slot.isCompleted) { + const voted = await tx.query.judgeVotes.findFirst({ + where: and( + eq(judgeVotes.judgeId, ctx.judge.id), + eq(judgeVotes.projectId, project.id), ), - eq(judgeQueue.hackathonId, input.hackathonId), - eq(judgeQueue.isCompleted, false), - ne(judgeQueue.judgeId, ctx.judge.id), - gt(judgeQueue.startedAt, claimedSince), - ), - columns: { projectId: true, startedAt: true }, - }); + columns: { id: true }, + }); + throw new TRPCError({ + code: "CONFLICT", + message: voted + ? `You already scored table ${project.tableNumber}.` + : `Time ran out on table ${project.tableNumber}, so it has gone back to be judged by someone else. Get your next table.`, + }); + } - // Latest claim per table, i.e. the best guess at when it frees up. - const claimedAt = new Map(); - for (const claim of claims) { - const at = claim.startedAt?.getTime() ?? 0; - claimedAt.set( - claim.projectId, - Math.max(claimedAt.get(claim.projectId) ?? 0, at), - ); + if (slot.arrivedAt) { + // Already started. Past the cutoff the look is void; the next + // dispatch closes the row (a write here would roll back with the throw). + if (isPastCutoff(slot.arrivedAt, now)) { + throw new TRPCError({ + code: "CONFLICT", + message: `Time ran out on table ${project.tableNumber}, so it has gone back to be judged by someone else. Get your next table.`, + }); + } + return { + project, + queueId: slot.id, + alreadyStarted: true, + arrivedAt: slot.arrivedAt, + serverNow: now, + }; } - // First free table in the judge's own order. When every remaining table is - // busy, take the one claimed longest ago rather than stalling: two judges at - // a table is awkward, an idle judge is worse, and both scores count. - const free = queue.find((row) => !claimedAt.has(row.projectId)); - const next = - free ?? - [...queue].sort( - (a, b) => - (claimedAt.get(a.projectId) ?? 0) - - (claimedAt.get(b.projectId) ?? 0), - )[0]; - - if (!next) { - return { done: true, project: null, remaining: 0 }; + // The walk ran long and the hold lapsed. Still theirs unless another + // judge has since been sent there. + if (!isLive(slot, now)) { + const others = await tx.query.judgeQueue.findMany({ + where: and( + eq(judgeQueue.projectId, project.id), + eq(judgeQueue.isCompleted, false), + ), + columns: { + judgeId: true, + startedAt: true, + arrivedAt: true, + isCompleted: true, + }, + }); + if (others.some((o) => o.judgeId !== ctx.judge.id && isLive(o, now))) { + throw new TRPCError({ + code: "CONFLICT", + message: `Another judge has table ${project.tableNumber} now. Get your next table.`, + }); + } } - // Claiming doubles as a heartbeat: the portal re-runs this while the judge - // has the project open, so the claim lapses only once they leave. - await db + await tx .update(judgeQueue) - .set({ startedAt: new Date() }) - .where(eq(judgeQueue.id, next.id)); + .set({ arrivedAt: now, startedAt: slot.startedAt ?? now }) + .where(eq(judgeQueue.id, slot.id)); return { - done: false, + project, + queueId: slot.id, + alreadyStarted: false, + arrivedAt: now, + serverNow: now, + }; + }); + }), + + // The judge's current table: the live one they hold, or a fresh pick from + // the pool. See dispatch.ts. + getNextTable: isJudge + .input(z.object({ hackathonId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + try { + const now = new Date(); + const next = await (ctx.db as DrizzleDB).transaction((tx) => + dispatchNext(tx as unknown as DrizzleDB, ctx.judge.id, input.hackathonId, now), + ); + if (next.done) return { done: true as const, project: null, serverNow: now }; + return { + done: false as const, project: next.project, - queueId: next.id, - remaining: queue.length, + queueId: next.queueId, + arrivedAt: next.arrivedAt, + serverNow: now, }; } catch (error) { // A NOT_FOUND or FORBIDDEN from inside is the judge's answer, not a 500. @@ -341,21 +338,24 @@ export const judgePortalRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - // Opening judging with nothing queued sent every judge straight to "All - // done, 0 of 0". Preparing (promote submissions, assign judges) is a - // separate step on the Judging page, so say so rather than open empty. + // Opening judging with nothing to judge sent every judge straight to + // "All done". Judges draw tables from the pool of judgeable projects, so + // that pool is what has to exist: promote submissions first. if (input.active) { - const [queued] = await (ctx.db as DrizzleDB) + const [judgeable] = await (ctx.db as DrizzleDB) .select({ n: sql`count(*)::int` }) - .from(judgeQueue) - // Any row, scored or not: reopening judging so a judge can fix a - // score, after every slot is done, must still be allowed. - .where(eq(judgeQueue.hackathonId, input.hackathonId)); - if ((queued?.n ?? 0) === 0) { + .from(judgingProjects) + .where( + and( + eq(judgingProjects.hackathonId, input.hackathonId), + isNull(judgingProjects.withdrawnAt), + ), + ); + if ((judgeable?.n ?? 0) === 0) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: - "No judge has a table to score yet. Prepare judging first: promote submissions and assign judges on the Judging page.", + "There are no projects to judge yet. Prepare judging first: it promotes submissions into judging on this page.", }); } } @@ -368,6 +368,10 @@ export const judgePortalRouter = createTRPCRouter({ return { success: true, judgingActive: updated?.judgingActive }; }), + // Writes or revises a score without moving on. A first score is held to the + // same clock as completeAndNext: the judge must have started the table and + // be inside the hard cutoff. Revising a score already on record is allowed + // while judging is open. submitVote: isJudge .input( z.object({ @@ -382,6 +386,8 @@ export const judgePortalRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + const now = new Date(); const totalScore = input.scoreCreativity + input.scoreImpact + @@ -391,7 +397,7 @@ export const judgePortalRouter = createTRPCRouter({ // Closing judging has to stop scores being written, or the upsert keeps // overwriting results after the organizers have called the winners. - const hackathon = await (ctx.db as DrizzleDB).query.hackathons.findFirst({ + const hackathon = await db.query.hackathons.findFirst({ where: eq(hackathons.id, ctx.judge.hackathonId), columns: { judgingActive: true }, }); @@ -402,16 +408,12 @@ export const judgePortalRouter = createTRPCRouter({ }); } - // A judge may only score what was routed to them. Without this any judge - // could score any project — including one assigned elsewhere or never - // visited — and it would count towards the rankings. Completed slots still - // match, so revising an earlier score keeps working. - const ownSlot = await (ctx.db as DrizzleDB).query.judgeQueue.findFirst({ + // A judge may only score a table the pool sent them to. + const ownSlot = await db.query.judgeQueue.findFirst({ where: and( eq(judgeQueue.judgeId, ctx.judge.id), eq(judgeQueue.projectId, input.projectId), ), - columns: { id: true }, }); if (!ownSlot) { throw new TRPCError({ @@ -420,8 +422,36 @@ export const judgePortalRouter = createTRPCRouter({ }); } - // Atomic upsert: INSERT or UPDATE if judge already voted on this project - const result = await (ctx.db as DrizzleDB) + const existing = await db.query.judgeVotes.findFirst({ + where: and( + eq(judgeVotes.judgeId, ctx.judge.id), + eq(judgeVotes.projectId, input.projectId), + ), + columns: { id: true }, + }); + if (!existing) { + if (ownSlot.isCompleted || !ownSlot.arrivedAt) { + throw new TRPCError({ + code: ownSlot.isCompleted ? "CONFLICT" : "BAD_REQUEST", + message: ownSlot.isCompleted + ? "Time ran out on this table, so it is being judged again by someone else." + : "Tap the table's NFC tag or scan its card before scoring.", + }); + } + if (isPastCutoff(ownSlot.arrivedAt, now)) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Time ran out on this table, so it is being judged again by someone else.", + }); + } + } + + const durationSeconds = ownSlot.arrivedAt + ? Math.max(0, Math.round((now.getTime() - ownSlot.arrivedAt.getTime()) / 1000)) + : input.durationSeconds; + + const result = await db .insert(judgeVotes) .values({ judgeId: ctx.judge.id, @@ -432,7 +462,7 @@ export const judgePortalRouter = createTRPCRouter({ scoreScope: input.scoreScope, scoreClarity: input.scoreClarity, scoreSoundness: input.scoreSoundness, - durationSeconds: input.durationSeconds, + durationSeconds: existing ? undefined : durationSeconds, comment: input.comment, }) .onConflictDoUpdate({ @@ -444,9 +474,8 @@ export const judgePortalRouter = createTRPCRouter({ scoreScope: sql`excluded.score_scope`, scoreClarity: sql`excluded.score_clarity`, scoreSoundness: sql`excluded.score_soundness`, - durationSeconds: sql`excluded.duration_seconds`, comment: sql`excluded.comment`, - updatedAt: new Date(), + updatedAt: now, }, }) .returning(); @@ -454,6 +483,10 @@ export const judgePortalRouter = createTRPCRouter({ return result[0]; }), + // Scores the current table and hands over the next one, in one transaction. + // Past the hard cutoff the score is refused and the look is void: the table + // goes back into the pool to be judged again, and the judge gets their next + // table with timedOut set so the page can say why. completeAndNext: isJudge .input( z.object({ @@ -469,6 +502,7 @@ export const judgePortalRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + const now = new Date(); const totalScore = input.scoreCreativity + input.scoreImpact + @@ -487,15 +521,16 @@ export const judgePortalRouter = createTRPCRouter({ }); } - return await (ctx.db as DrizzleDB).transaction(async (tx) => { - // 1. The queue slot is addressed by id alone, so it has to be read back and + return await (ctx.db as DrizzleDB).transaction(async (rawTx) => { + const tx = rawTx as unknown as DrizzleDB; + await lockDispatch(tx, ctx.judge.hackathonId); + + // 1. The slot is addressed by id alone, so it has to be read back and // vetted before any score is written against it. const queueItem = await tx.query.judgeQueue.findFirst({ where: eq(judgeQueue.id, input.queueId), }); - // A queue id that no longer resolves is tolerated — the judge may be retrying - // — but a row that does resolve has to be this judge's own slot. if (queueItem) { if (queueItem.judgeId && queueItem.judgeId !== ctx.judge.id) { throw new TRPCError({ @@ -509,45 +544,81 @@ export const judgePortalRouter = createTRPCRouter({ message: "Queue item does not belong to this hackathon", }); } - // The slot being closed and the project being scored must be the same one. - // Comparing hackathons proves nothing, since every slot a judge owns is - // already in theirs — so without this a judge could score Y while slot X is - // stamped complete, leaving X unscored but counted as visited. + // The slot being closed and the project being scored must be the same + // one, or a judge could score Y while X is stamped complete. if (queueItem.projectId !== input.projectId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Queue item does not match the project being scored", }); } - } else { - // Tolerating a missing slot must not drop the ownership test — otherwise any - // queueId matching no row scores any project. - const ownSlot = await tx.query.judgeQueue.findFirst({ + } + + // A queue id that no longer resolves is tolerated (the judge may be + // retrying) but never drops the ownership test. + const slot = + queueItem ?? + (await tx.query.judgeQueue.findFirst({ where: and( eq(judgeQueue.judgeId, ctx.judge.id), eq(judgeQueue.projectId, input.projectId), ), - columns: { id: true }, + })); + if (!slot) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This project is not in your judging queue", }); - if (!ownSlot) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This project is not in your judging queue", - }); - } } - // Measured from the scan when there is one. The client's figure starts when - // the card rendered, which includes the walk to the table; arrivedAt is - // server-stamped and cannot be shaped by a stale tab or a skewed clock. - const measuredSeconds = queueItem?.arrivedAt - ? Math.max( - 0, - Math.round((Date.now() - queueItem.arrivedAt.getTime()) / 1000), - ) - : input.durationSeconds; - - // 2. Atomic upsert vote + const existing = await tx.query.judgeVotes.findFirst({ + where: and( + eq(judgeVotes.judgeId, ctx.judge.id), + eq(judgeVotes.projectId, input.projectId), + ), + columns: { id: true }, + }); + + const handOver = async (timedOut: boolean) => { + const next = await dispatchNext(tx, ctx.judge.id, ctx.judge.hackathonId, now); + return next.done + ? { done: true as const, nextProject: null, timedOut, serverNow: now } + : { + done: false as const, + nextProject: next.project, + nextQueueId: next.queueId, + nextArrivedAt: next.arrivedAt, + timedOut, + serverNow: now, + }; + }; + + // A retry of a completion that already landed: hand back the same next + // table rather than scoring twice. + if (slot.isCompleted) return handOver(!existing); + + if (!slot.arrivedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Tap the table's NFC tag or scan its card before scoring.", + }); + } + + if (isPastCutoff(slot.arrivedAt, now)) { + await tx + .update(judgeQueue) + .set({ isCompleted: true, completedAt: now }) + .where(eq(judgeQueue.id, slot.id)); + return handOver(true); + } + + // Measured from the server-stamped arrival, which a stale tab or a + // skewed phone clock cannot shape. + const measuredSeconds = Math.max( + 0, + Math.round((now.getTime() - slot.arrivedAt.getTime()) / 1000), + ); + await tx .insert(judgeVotes) .values({ @@ -573,148 +644,84 @@ export const judgePortalRouter = createTRPCRouter({ scoreSoundness: sql`excluded.score_soundness`, durationSeconds: sql`excluded.duration_seconds`, comment: sql`excluded.comment`, - updatedAt: new Date(), + updatedAt: now, }, }); - // 3. Mark queue item as completed await tx .update(judgeQueue) - .set({ - isCompleted: true, - completedAt: new Date(), - }) - .where(eq(judgeQueue.id, input.queueId)); - - // 4. Get next uncompleted queue item - const nextInQueue = await tx.query.judgeQueue.findFirst({ - where: and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.hackathonId, ctx.judge.hackathonId), - eq(judgeQueue.isCompleted, false), - ), - with: { - project: true, - }, - orderBy: [asc(judgeQueue.order)], - }); - - if (!nextInQueue) { - return { done: true, nextProject: null }; - } - - // Handing a table over is what claims it. Stamping only in getNextTable left - // every project after a judge's first one unclaimed, so two judges could be - // sent to the same table. - await tx - .update(judgeQueue) - .set({ startedAt: new Date() }) - .where(eq(judgeQueue.id, nextInQueue.id)); + .set({ isCompleted: true, completedAt: now }) + .where(eq(judgeQueue.id, slot.id)); - return { - done: false, - nextProject: nextInQueue.project, - nextQueueId: nextInQueue.id, - }; + return handOver(false); }); }), + // Passes on the current table without scoring it. The project stays in the + // pool for other judges; this judge is not sent back to it. skipProject: isJudge - .input( - z.object({ - queueId: z.string().uuid(), - }), - ) + .input(z.object({ queueId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - return await (ctx.db as DrizzleDB).transaction(async (tx) => { - // Get the queue item to find the hackathon + const now = new Date(); + return await (ctx.db as DrizzleDB).transaction(async (rawTx) => { + const tx = rawTx as unknown as DrizzleDB; + const queueItem = await tx.query.judgeQueue.findFirst({ where: eq(judgeQueue.id, input.queueId), - with: { project: true }, }); - // The queue id addresses any judge's slot, so a row owned by someone else has - // to read as missing rather than as an actionable item. - if ( - !queueItem || - (queueItem.judgeId && queueItem.judgeId !== ctx.judge.id) - ) { + // The queue id addresses any judge's slot, so a row owned by someone + // else has to read as missing rather than as an actionable item. + if (!queueItem || queueItem.judgeId !== ctx.judge.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Queue item not found", }); } - // Atomically move this item to the end of the queue. Walking away drops the - // claim too, so another judge can take the table immediately. - await tx - .update(judgeQueue) - .set({ - order: sql`(SELECT COALESCE(MAX(${judgeQueue.order}), 0) + 1 FROM ${judgeQueue} WHERE ${judgeQueue.judgeId} = ${ctx.judge.id} AND ${judgeQueue.hackathonId} = ${queueItem.hackathonId})`, - startedAt: null, - }) - .where(eq(judgeQueue.id, input.queueId)); - - // Get the next uncompleted item - const nextInQueue = await tx.query.judgeQueue.findFirst({ - where: and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.hackathonId, queueItem.hackathonId), - eq(judgeQueue.isCompleted, false), - ), - with: { - project: true, - }, - orderBy: [asc(judgeQueue.order)], - }); + await lockDispatch(tx, queueItem.hackathonId); - if (!nextInQueue || nextInQueue.id === input.queueId) { - // Only this one project left — the last cannot be skipped. The caller renders - // a project card, so hand back the project, not the queue row. - return { - done: false, - skippedToEnd: true, - project: queueItem.project, - queueId: input.queueId, - }; + if (!queueItem.isCompleted) { + await tx + .update(judgeQueue) + .set({ isCompleted: true, completedAt: now }) + .where(eq(judgeQueue.id, queueItem.id)); } - // Claim the table being handed over, same as getNextTable does. - await tx - .update(judgeQueue) - .set({ startedAt: new Date() }) - .where(eq(judgeQueue.id, nextInQueue.id)); - - return { - done: false, - skippedToEnd: false, - project: nextInQueue.project, - queueId: nextInQueue.id, - }; + const next = await dispatchNext(tx, ctx.judge.id, queueItem.hackathonId, now); + return next.done + ? { done: true as const, project: null, queueId: null, serverNow: now } + : { + done: false as const, + project: next.project, + queueId: next.queueId, + arrivedAt: next.arrivedAt, + serverNow: now, + }; }); }), + // Kept for the judge page's "can't finish this one" path. With a shared pool + // nothing needs reassigning by hand: closing the slot without a score is + // enough for the next judge who asks to be sent there. forceSkipOvertime: isJudge .input(z.object({ queueId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { - return await (ctx.db as DrizzleDB).transaction(async (tx) => { + const now = new Date(); + return await (ctx.db as DrizzleDB).transaction(async (rawTx) => { + const tx = rawTx as unknown as DrizzleDB; + const queueItem = await tx.query.judgeQueue.findFirst({ where: eq(judgeQueue.id, input.queueId), - with: { project: true }, }); - // The queue id addresses any judge's slot, so a row owned by someone else has - // to read as missing rather than as an actionable item. - if ( - !queueItem || - (queueItem.judgeId && queueItem.judgeId !== ctx.judge.id) - ) + if (!queueItem || queueItem.judgeId !== ctx.judge.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Queue item not found", }); + } - // A finished slot has nothing to skip. Repeating the call on one would - // hand the project to one more judge each time. + // A finished slot has nothing to skip. if (queueItem.isCompleted) { throw new TRPCError({ code: "BAD_REQUEST", @@ -722,166 +729,44 @@ export const judgePortalRouter = createTRPCRouter({ }); } - // Mark completed (no vote submitted) + await lockDispatch(tx, queueItem.hackathonId); await tx .update(judgeQueue) - .set({ isCompleted: true, completedAt: new Date() }) - .where(eq(judgeQueue.id, input.queueId)); - - let reassigned = false; - - // Try to reassign to another judge with the same track - const myAssignment = await tx.query.judgeAssignments.findFirst({ - where: and( - eq(judgeAssignments.judgeId, ctx.judge.id), - eq(judgeAssignments.hackathonId, queueItem.hackathonId), - ), - }); - - if (myAssignment) { - // Find other judges assigned to the same hackathon - const otherAssignments = await tx.query.judgeAssignments.findMany({ - where: eq(judgeAssignments.hackathonId, queueItem.hackathonId), - with: { judge: true }, - }); - - // Get the project's tracks for matching - const projectTracks = queueItem.project?.tracks || []; - - // Two queries for the whole candidate set, not two per candidate. This runs - // inside an open transaction during judging: at 40 judges the per-candidate - // version was ~80 sequential round trips holding a pool connection. - const [holders, workloads] = await Promise.all([ - tx - .select({ judgeId: judgeQueue.judgeId }) - .from(judgeQueue) - .where(eq(judgeQueue.projectId, queueItem.projectId)), - tx - .select({ - judgeId: judgeQueue.judgeId, - remaining: sql`count(*)::int`, - }) - .from(judgeQueue) - .where( - and( - eq(judgeQueue.hackathonId, queueItem.hackathonId), - eq(judgeQueue.isCompleted, false), - ), - ) - .groupBy(judgeQueue.judgeId), - ]); - - const alreadyHolding = new Set(holders.map((row) => row.judgeId)); - const remainingByJudge = new Map( - workloads.map((row) => [row.judgeId, row.remaining]), - ); - - const candidates: { - judgeId: string; - trackMatch: boolean; - remaining: number; - }[] = []; - - for (const other of otherAssignments) { - if (other.judgeId === ctx.judge.id) continue; - - // A judge who is not active can never open the portal, so handing them the - // project strands it with nobody able to score it. - if (!other.judge?.isActive) continue; - - if (alreadyHolding.has(other.judgeId)) continue; - - // Check track match: judge's assigned track overlaps with project's tracks - const trackMatch = other.track - ? projectTracks.includes(other.track) - : false; - - candidates.push({ - judgeId: other.judgeId, - trackMatch, - // A judge with nothing left has no group row at all, which is the lightest - // possible load rather than a missing one. - remaining: remainingByJudge.get(other.judgeId) ?? 0, - }); - } - - // Sort: same-track first, then by fewest remaining projects (lightest load) - candidates.sort((a, b) => { - if (a.trackMatch !== b.trackMatch) return a.trackMatch ? -1 : 1; - return a.remaining - b.remaining; - }); - - const best = candidates[0]; - if (best) { - // Atomic order assignment via SQL subquery - await tx.insert(judgeQueue).values({ - judgeId: best.judgeId, - hackathonId: queueItem.hackathonId, - projectId: queueItem.projectId, - order: sql`(SELECT COALESCE(MAX(${judgeQueue.order}), 0) + 1 FROM ${judgeQueue} WHERE ${judgeQueue.judgeId} = ${best.judgeId} AND ${judgeQueue.hackathonId} = ${queueItem.hackathonId})`, - }); - reassigned = true; - } - } - - // Get next project for this judge - const nextInQueue = await tx.query.judgeQueue.findFirst({ - where: and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.hackathonId, queueItem.hackathonId), - eq(judgeQueue.isCompleted, false), - ), - with: { project: true }, - orderBy: [asc(judgeQueue.order)], - }); - - // Claim the table being handed over, as completeAndNext and skipProject do. - // Without this the slot stays unclaimed and the next judge asking for work is - // sent to the table this judge just walked up to. - if (nextInQueue) { - await tx - .update(judgeQueue) - .set({ startedAt: new Date() }) - .where(eq(judgeQueue.id, nextInQueue.id)); - } + .set({ isCompleted: true, completedAt: now }) + .where(eq(judgeQueue.id, queueItem.id)); + const next = await dispatchNext(tx, ctx.judge.id, queueItem.hackathonId, now); return { - done: !nextInQueue, - project: nextInQueue?.project ?? null, - queueId: nextInQueue?.id ?? null, - reassigned, + done: next.done, + project: next.done ? null : next.project, + queueId: next.done ? null : next.queueId, + // Back in the pool: the next judge who asks is sent there. + reassigned: true, }; }); }), + // Scored so far, out of the projects this judge is eligible for. getProgress: isJudge .input(z.object({ hackathonId: z.string().uuid() })) .query(async ({ ctx, input }) => { try { - const totalResult = await (ctx.db as DrizzleDB) - .select({ count: sql`count(*)` }) - .from(judgeQueue) - .where( - and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.hackathonId, input.hackathonId), - ), - ); - - const completedResult = await (ctx.db as DrizzleDB) - .select({ count: sql`count(*)` }) - .from(judgeQueue) - .where( + const db = ctx.db as DrizzleDB; + const { pool } = await loadPool(db, ctx.judge.id, input.hackathonId); + const [scored] = await db + .select({ n: sql`count(*)::int` }) + .from(judgeVotes) + .innerJoin( + judgingProjects, and( - eq(judgeQueue.judgeId, ctx.judge.id), - eq(judgeQueue.hackathonId, input.hackathonId), - eq(judgeQueue.isCompleted, true), + eq(judgingProjects.id, judgeVotes.projectId), + eq(judgingProjects.hackathonId, input.hackathonId), ), - ); - - const total = Number(totalResult[0]?.count || 0); - const completed = Number(completedResult[0]?.count || 0); + ) + .where(eq(judgeVotes.judgeId, ctx.judge.id)); + const total = pool.length; + const completed = scored?.n ?? 0; return { total, completed, diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 92b373af..97f28370 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -387,10 +387,6 @@ const CACHE_INVALIDATION_MAP: Record = { "hackathon:*:judge-analytics", ], "judge.toggleJudging": ["hackathon:*"], - "judge.assignJudgesToProjects": [ - "hackathon:*:rankings", - "hackathon:*:judge-analytics", - ], // Promotion creates judgeable projects and flips submissions to "judging", so // the public project list and the rankings view both move. "judge.promoteSubmissions": [ diff --git a/packages/db/src/schemas/judge.ts b/packages/db/src/schemas/judge.ts index d1616b48..a8286cde 100644 --- a/packages/db/src/schemas/judge.ts +++ b/packages/db/src/schemas/judge.ts @@ -219,7 +219,8 @@ export const hackathonResults = pgTable( ], ); -// Track which tables a judge still needs to visit +// One row per visit: written when the judging pool hands a judge a table +// (api routers/judge/dispatch.ts), not as a list built in advance. export const judgeQueue = pgTable( "judge_queue", { @@ -233,17 +234,16 @@ export const judgeQueue = pgTable( projectId: uuid("project_id") .notNull() .references(() => judgingProjects.id, { onDelete: "cascade" }), - order: integer("order").notNull(), // order to visit + order: integer("order").notNull(), // nth table this judge was handed isCompleted: boolean("is_completed").notNull().default(false), completedAt: timestamp("completed_at"), - // Stamped when this project is handed to the judge, so a second judge whose - // queue reaches the same table passes over it and comes back later. A claim - // only while recent (JUDGE_CLAIM_MINUTES) — a judge who closes the tab - // releases the table rather than blocking it until an admin steps in. + // Stamped when the pool hands this table to the judge. Holds the table + // while they walk over (JUDGE_WALK_LIMIT_SECONDS); a judge who never + // arrives releases it rather than blocking it until an admin steps in. startedAt: timestamp("started_at"), - // When the judge scanned the table's QR and actually began. Distinct from - // startedAt, the claim stamped when the queue hands the table over: the gap - // is walking, queueing and finding the table, none of which is judging. + // When the judge tapped the table's NFC tag or scanned its card and + // actually began. The judging clock and the hard cutoff run from here; + // the gap from startedAt is walking, none of which is judging. arrivedAt: timestamp("arrived_at"), }, (table) => [ diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index f4ee827b..79f8a93f 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -73,23 +73,10 @@ export default function AdminResultsPage() { message: string | null; error: string | null; }>({ busy: false, message: null, error: null }); - // The edition whose assign call was refused. Rebuild anyway sends force - // for this id, not whatever is selected now — a late CONFLICT from A must - // not rebuild B. - const [assignConflictId, setAssignConflictId] = useState( - null, - ); const selectedHackathonRef = useRef(selectedHackathon); const prepGen = useRef(0); const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation(); - const assignJudges = trpc.judge.assignJudgesToProjects.useMutation(); - - const isAssignConflict = (error: unknown) => - typeof error === "object" && - error !== null && - "data" in error && - (error as { data?: { code?: string } }).data?.code === "CONFLICT"; const stillThisRun = (hackathonId: string, gen: number) => judgingPrepIsCurrent( @@ -114,7 +101,6 @@ export default function AdminResultsPage() { if (!stillThisRun(hackathonId, gen)) return; await refetchJudgingStatus(); if (!stillThisRun(hackathonId, gen)) return; - setAssignConflictId(null); setPrepState({ busy: false, error: null, message }); }; @@ -122,27 +108,21 @@ export default function AdminResultsPage() { const hackathonId = selectedHackathon; if (!hackathonId) return; const gen = ++prepGen.current; - setAssignConflictId(null); setPrepState({ busy: true, message: null, error: null }); try { const promoted = await promoteSubmissions.mutateAsync({ hackathonId, }); - const assigned = await assignJudges.mutateAsync({ - hackathonId, - }); if (!stillThisRun(hackathonId, gen)) return; - const warning = promoted.queuesNeedRebuild - ? " One or more new projects carry a track no active judge covers — fix the track, then run this again." - : ""; + // No queues to build: judges draw from the pool of judgeable projects as + // soon as judging is open, so syncing is the whole of preparing. await finishPrepare( hackathonId, gen, - `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`, + `Synced ${promoted.created} new submission(s) of ${promoted.total}. Judges draw tables from these as soon as judging is open. Print the table cards next.`, ); } catch (e) { if (!stillThisRun(hackathonId, gen)) return; - setAssignConflictId(isAssignConflict(e) ? hackathonId : null); setPrepState({ busy: false, message: null, @@ -151,36 +131,6 @@ export default function AdminResultsPage() { } }; - // The server names how many completed slots (or that judging is live) and - // asks for confirmation. This is the only control that sends force: true — - // /admin/setup used to, and now redirects here. - const rebuildQueuesAnyway = async () => { - const hackathonId = assignConflictId; - if (!hackathonId || hackathonId !== selectedHackathonRef.current) return; - const gen = ++prepGen.current; - setPrepState((s) => ({ ...s, busy: true })); - try { - const assigned = await assignJudges.mutateAsync({ - hackathonId, - force: true, - }); - if (!stillThisRun(hackathonId, gen)) return; - await finishPrepare( - hackathonId, - gen, - `Rebuilt queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Completed slots were kept.`, - ); - } catch (e) { - if (!stillThisRun(hackathonId, gen)) return; - setAssignConflictId(isAssignConflict(e) ? hackathonId : null); - setPrepState({ - busy: false, - message: null, - error: e instanceof Error ? e.message : "Could not rebuild queues.", - }); - } - }; - useEffect(() => { if (status === "unauthenticated") { router.push(loginHref()); @@ -192,11 +142,10 @@ export default function AdminResultsPage() { setSelectedHackathon(hackathons[0].id); } - // A new selection drops the previous edition's prep result and conflict. + // A new selection drops the previous edition's prep result. const [prepShownFor, setPrepShownFor] = useState(selectedHackathon); if (prepShownFor !== selectedHackathon) { setPrepShownFor(selectedHackathon); - setAssignConflictId(null); setPrepState({ busy: false, message: null, error: null }); } @@ -365,19 +314,13 @@ export default function AdminResultsPage() { Prepare judging

- Syncs submissions into judging and builds every judge's - queue. Run it before starting, and again after late - submissions. + Syncs submissions into judging and gives each a table. Safe + to run again during judging: late submissions join the pool.

- )} - + {prepState.error} +

)} diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index df26077b..b7a5c579 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -42,6 +42,32 @@ const BLANK: Scores = { scoreSoundness: 5, }; +// Mirrors packages/api/src/routers/judge/dispatch.ts: amber at the target, +// form locked at the hard cutoff. The server enforces the cutoff; this only +// shows it. +const TARGET_SECONDS = 180; +const HARD_LIMIT_SECONDS = 240; + +const TIME_UP_MESSAGE = + "Time ran out on that table before a score went in, so it goes back to be judged by someone else. Here is your next table."; + +/** + * A server timestamp on this phone's clock. The judging clock has to agree + * with the server's cutoff, and a phone set a minute off would otherwise + * show 3:00 when the server already counts 4:00. + */ +function toLocalClock( + serverTime: Date | string | null, + serverNow: Date | string, +): number | null { + if (!serverTime) return null; + const offset = Date.now() - new Date(serverNow).getTime(); + return new Date(serverTime).getTime() + offset; +} + +const mmss = (seconds: number) => + `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; + export default function JudgeHackathonPage() { const { data: session, status } = useSession(); const router = useRouter(); @@ -74,12 +100,20 @@ export default function JudgeHackathonPage() { const [comment, setComment] = useState(""); const [error, setError] = useState(""); const [showScanner, setShowScanner] = useState(false); - /** Server-stamped arrival. Until the judge scans the table, the scoring form - * stays closed so the clock cannot start from across the room. */ - const [arrived, setArrived] = useState(false); - /** Set when a skip had nowhere to rotate to — this is the judge's last - * uncompleted table, so "skip" cannot move them off it. */ - const [stranded, setStranded] = useState(false); + /** When the judging clock started, on this device's clock: the server's + * arrival stamp shifted by the server/phone offset. Null until the judge + * taps or scans the table, and the scoring form stays shut until then. */ + const [clockStart, setClockStart] = useState(null); + const arrived = clockStart !== null; + const [tick, setTick] = useState(() => Date.now()); + useEffect(() => { + if (clockStart === null) return; + const id = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(id); + }, [clockStart]); + const elapsed = + clockStart === null ? 0 : Math.max(0, Math.floor((tick - clockStart) / 1000)); + const timeUp = clockStart !== null && elapsed >= HARD_LIMIT_SECONDS; const [done, setDone] = useState(false); const [startedAt, setStartedAt] = useState(() => Date.now()); @@ -138,21 +172,25 @@ export default function JudgeHackathonPage() { project: (nextTable.data.project as Project) ?? null, queueId: nextTable.data.queueId ?? null, }); + // A reload mid-table keeps the clock it already had. + setClockStart( + toLocalClock(nextTable.data.arrivedAt ?? null, nextTable.data.serverNow), + ); } } const advance = ( project: Project | null, queueId: string | null, - // startByQrCode is the one caller that has already arrived; every other - // path hands over a table the judge still has to walk to. - hasArrived = false, + // Set when the table has already been started (the tap or scan); every + // other hand-over is a table the judge still has to walk to. + clock: number | null = null, ) => { setScores(BLANK); setComment(""); setError(""); setStartedAt(Date.now()); - setArrived(hasArrived); + setClockStart(clock); progress.refetch(); if (!project) { setDone(true); @@ -195,6 +233,7 @@ export default function JudgeHackathonPage() { advance( data.done ? null : ((data.project as Project) ?? null), data.queueId ?? null, + data.done ? null : toLocalClock(data.arrivedAt ?? null, data.serverNow), ); setError("That table was withdrawn by an organiser. Here is your next one."); }); @@ -204,11 +243,14 @@ export default function JudgeHackathonPage() { // table a second time, and both already return the next project to show. const complete = trpc.judge.completeAndNext.useMutation({ onSuccess: (res) => { - if (res.done) { - advance(null, null); - return; - } - advance((res.nextProject as Project) ?? null, res.nextQueueId ?? null); + if (res.done) advance(null, null); + else + advance( + (res.nextProject as Project) ?? null, + res.nextQueueId ?? null, + toLocalClock(res.nextArrivedAt ?? null, res.serverNow), + ); + if (res.timedOut) setError(TIME_UP_MESSAGE); }, onError: recoverOrReport, }); @@ -224,7 +266,11 @@ export default function JudgeHackathonPage() { onSuccess: (res) => { setShowScanner(false); setError(""); - advance((res.project as Project) ?? null, res.queueId ?? null, true); + advance( + (res.project as Project) ?? null, + res.queueId ?? null, + toLocalClock(res.arrivedAt, res.serverNow), + ); }, onError: (e) => { setShowScanner(false); @@ -232,29 +278,15 @@ export default function JudgeHackathonPage() { }, }); + // Passing on a table returns it to the pool for another judge; this judge + // is not sent back to it. Also what "time's up" uses to move on. const skip = trpc.judge.skipProject.useMutation({ - onSuccess: (res) => { - // Skipping the last uncompleted item hands back the same project: there - // is nothing to rotate to. Saying so is the difference between a button - // that looks broken and one that explains the only way out. - setStranded(res.skippedToEnd === true); - advance((res.project as Project) ?? null, res.queueId ?? null); - }, - onError: recoverOrReport, - }); - - // The escape from a table nobody is standing at: marks it done without a - // score and hands the project to another judge, so it still gets seen. - const forceSkip = trpc.judge.forceSkipOvertime.useMutation({ - onSuccess: (res) => { - setStranded(false); - if (!res.reassigned) { - setError( - "Marked done, but no other judge was free to take it — flag this table to an organiser.", - ); - } - advance((res.project as Project) ?? null, res.queueId ?? null); - }, + onSuccess: (res) => + advance( + res.done ? null : ((res.project as Project) ?? null), + res.queueId ?? null, + res.done ? null : toLocalClock(res.arrivedAt ?? null, res.serverNow), + ), onError: recoverOrReport, }); @@ -410,7 +442,7 @@ export default function JudgeHackathonPage() { const project = current?.project; const busy = - complete.isPending || skip.isPending || forceSkip.isPending; + complete.isPending || skip.isPending; return (
@@ -425,19 +457,14 @@ export default function JudgeHackathonPage() { {progress.data && (

- {progress.data.completed} / {progress.data.total} judged + {progress.data.completed} judged

)}
- {progress.data && progress.data.total > 0 && ( -
-
-
- )} + {/* No progress bar: judges draw from a shared pool, so a share of + every eligible project says nothing about how a judge is doing. */} +
{error && (
@@ -459,9 +486,9 @@ export default function JudgeHackathonPage() {

{progress.data?.total === 0 - ? "No projects are assigned to you yet. Tell an organiser; they assign tables from the Judging page." + ? "There are no projects for you to judge yet. Tell an organiser." : (progress.data?.total ?? 0) > 0 - ? "You have judged every project assigned to you. Thank you." + ? "You have been to every project you can judge. Thank you." : progress.isError ? "Couldn't load your progress. Refresh the page." : "Loading your queue…"} @@ -529,9 +556,53 @@ export default function JudgeHackathonPage() { > {startByQr.isPending ? "Starting…" : "Scan Table Code"} + ) : ( + {/* The clock runs from the tap or scan. Amber at 3:00; at 4:00 + the form locks and the table goes back into the pool. */} +

= TARGET_SECONDS + ? "border-amber-500/40 bg-amber-500/10" + : "border-[var(--border-subtle)] bg-[var(--bg-secondary)]" + }`} + > + + {timeUp + ? "Time's up" + : elapsed >= TARGET_SECONDS + ? `Wrap up: score locks at ${mmss(HARD_LIMIT_SECONDS)}` + : `Aim for ${mmss(TARGET_SECONDS)}`} + + = TARGET_SECONDS + ? "text-amber-300" + : "text-[var(--text-primary)]" + }`} + > + {mmss(Math.min(elapsed, HARD_LIMIT_SECONDS))} + +
+ {CRITERIA.map((c) => (
@@ -581,33 +652,25 @@ export default function JudgeHackathonPage() { />
- {stranded && ( -
-

- This is the last table left in your queue, so there is - nothing to skip to. If nobody is here, hand it to another - judge instead — it will still get scored. + {timeUp ? ( +

+

+ No score went in within {mmss(HARD_LIMIT_SECONDS)}, so this + table goes back to be judged by someone else.

- )} - + ) : (

Total {total}{" "} @@ -624,7 +687,7 @@ export default function JudgeHackathonPage() { }} className="px-5 py-2.5 rounded-sm border border-[var(--border-medium)] bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)] hover:border-[var(--border-hover)] transition-ui text-xs font-bold uppercase tracking-widest disabled:opacity-30" > - {skip.isPending ? "Skipping…" : "Skip for now"} + {skip.isPending ? "Passing…" : "Pass on this table"}

+ )} )} diff --git a/sites/mainweb/components/admin/hackathons/JudgeLiveBoard.tsx b/sites/mainweb/components/admin/hackathons/JudgeLiveBoard.tsx index 5049e504..d3244f10 100644 --- a/sites/mainweb/components/admin/hackathons/JudgeLiveBoard.tsx +++ b/sites/mainweb/components/admin/hackathons/JudgeLiveBoard.tsx @@ -21,11 +21,6 @@ const STATUS: Record = { label: "Between tables", className: "border-amber-500/30 bg-amber-500/10 text-amber-400", }, - done: { - label: "Done", - className: - "border-[var(--border-subtle)] bg-[var(--bg-secondary)] text-[var(--text-muted)]", - }, suspended: { label: "Suspended", className: @@ -34,6 +29,7 @@ const STATUS: Record = { }; const mins = (n: number | null) => (n === null ? "-" : `${n}m`); +const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; /** Where every judge is, refreshed while judging runs. */ export function JudgeLiveBoard({ @@ -65,17 +61,69 @@ export function JudgeLiveBoard({
{data && (

- {data.totals.completed}/{data.totals.assigned} visits ·{" "} - {data.totals.percent}% + {data.totals.scored} scored + {data.totals.voided > 0 && ( + + {" "} + · {data.totals.voided} timed out or passed + + )}

)}
+ {/* Whether the judges who turned up can finish: coverage so far and the + measured pace, never a planned head count. */} + {data && data.coverage.projects > 0 && ( +
+ {[ + { + label: `At ${data.coverage.targetLooks}+ looks`, + value: `${data.coverage.atTarget}/${data.coverage.projects}`, + }, + { + label: "Not yet seen", + value: String(data.coverage.unseen), + alert: data.coverage.unseen > 0 && active, + }, + { + label: "Judges active", + value: String(data.pace.activeJudges), + }, + { + label: `To ${data.coverage.targetLooks} looks each`, + value: + data.pace.minutesToTarget === null + ? "-" + : data.pace.minutesToTarget === 0 + ? "Done" + : `~${data.pace.minutesToTarget}m`, + }, + ].map((stat) => ( +
+
+ {stat.label} +
+
+ {stat.value} +
+
+ ))} +
+ )} + {isLoading ? (

Loading judges…

) : judges.length === 0 ? (

- No queues yet. Press Prepare judging above. + No judges yet. Approve judges on the hackathon's Judges tab.

) : (
@@ -85,7 +133,7 @@ export function JudgeLiveBoard({ Judge Status At - Done + Scored Idle Median @@ -112,7 +160,8 @@ export function JudgeLiveBoard({ <> Table {j.current.tableNumber ?? "?"} - {mins(j.current.onItMinutes)} on it + {j.current.phase === "walking" ? "walking" : "at table"}{" "} + {mmss(j.current.seconds)} ) : ( @@ -120,11 +169,14 @@ export function JudgeLiveBoard({ )} - {j.completed}/{j.assigned} + {j.scored} + {j.voided > 0 && ( + · {j.voided} void + )} = 20 && j.status !== "done" + (j.idleMinutes ?? 0) >= 10 && j.status !== "suspended" ? "text-red-400" : "text-[var(--text-muted)]" }`} diff --git a/turbo.json b/turbo.json index ed91d6e2..9a01a7cf 100644 --- a/turbo.json +++ b/turbo.json @@ -90,5 +90,10 @@ "DDOS_BURST_WINDOW_MS", "DDOS_CLEANUP_INTERVAL_MS" ], - "globalPassThroughEnv": ["NODE_ENV", "CI", "npm_lifecycle_event"] + "globalPassThroughEnv": [ + "NODE_ENV", + "CI", + "npm_lifecycle_event", + "JUDGING_TEST_DATABASE_URL" + ] } From 726bad1bf66b49316d14cf340217745fb2569619 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sat, 3 Oct 2026 21:00:23 -0400 Subject: [PATCH 2/3] fix(judging): close withdrawn visits; count main-panel looks only A live visit to a project withdrawn mid-handoff was never closed, so it kept matching as the judge's live visit and every later dispatch claimed another table. Close it with the lapsed visits. liveProgress counted sponsor scores toward the main panel's two-look floor and pace. Count main-group votes only, using the same grouping as dispatch (now shared as loadGroups). --- packages/api/src/routers/judge/admin.ts | 14 +++-- .../api/src/routers/judge/dispatch.db.test.ts | 23 +++++++ packages/api/src/routers/judge/dispatch.ts | 60 ++++++++++++------- 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index f2e569d4..1a11b025 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -16,7 +16,7 @@ import { eq, and, asc, sql, inArray, isNull } from "drizzle-orm"; import { isAdmin, isSuperAdmin } from "../../middleware/procedures"; import { CacheKeys, invalidatePortalContext } from "../../middleware/cache"; import type { DrizzleDB } from "@query/db"; -import { isLive, loadPool } from "./dispatch"; +import { isLive, loadGroups, loadPool } from "./dispatch"; // Judges draw tables from a shared pool (dispatch.ts) instead of holding a // list built in advance. Clears rows an earlier version built that the judge @@ -736,11 +736,15 @@ export const judgeAdminRouter = createTRPCRouter({ (b.idleMinutes ?? 0) - (a.idleMinutes ?? 0), ); - // How evenly the projects have been seen. Two looks each is the floor - // the pool works towards before anyone gets a third. + // How evenly the main panel has seen the projects. Two looks each is the + // floor the pool works towards before anyone gets a third. Sponsor and + // special-label scores cover their own prize, as in dispatch, so they + // count towards neither the floor nor the pace that estimates it. + const { groupByJudge } = await loadGroups(db, input.hackathonId); + const mainVotes = voteRows.filter((v) => groupByJudge(v.judgeId) === "main"); const TARGET_LOOKS = 2; const looks = new Map(projectRows.map((p) => [p.id, 0])); - for (const v of voteRows) { + for (const v of mainVotes) { if (looks.has(v.projectId)) looks.set(v.projectId, looks.get(v.projectId)! + 1); } const counts = [...looks.values()]; @@ -750,7 +754,7 @@ export const judgeAdminRouter = createTRPCRouter({ // judges who are actually on the floor, not the number who signed up. const WINDOW_MIN = 30; const windowStart = now.getTime() - WINDOW_MIN * 60_000; - const recentScores = voteRows.filter( + const recentScores = mainVotes.filter( (v) => v.votedAt && v.votedAt.getTime() >= windowStart, ).length; const activeJudges = judgesOut.filter( diff --git a/packages/api/src/routers/judge/dispatch.db.test.ts b/packages/api/src/routers/judge/dispatch.db.test.ts index b2f9cf82..de11abce 100644 --- a/packages/api/src/routers/judge/dispatch.db.test.ts +++ b/packages/api/src/routers/judge/dispatch.db.test.ts @@ -245,6 +245,29 @@ describe.skipIf(!URL)("judging pool on Postgres", () => { expect(rows).toHaveLength(1); }); + it("closes a live visit to a withdrawn project instead of holding it open beside new ones", async () => { + const { hackathonId, judgeIds } = await seed({ projects: 5, judges: 1 }); + const t0 = new Date("2027-02-28T13:00:00Z"); + const first = await dispatch(judgeIds[0]!, hackathonId, t0); + if (first.done) throw new Error("expected a table"); + await db + .update(judgingProjects) + .set({ withdrawnAt: at(t0, 10) }) + .where(eq(judgingProjects.id, first.project.id)); + + const second = await dispatch(judgeIds[0]!, hackathonId, at(t0, 20)); + const third = await dispatch(judgeIds[0]!, hackathonId, at(t0, 30)); + if (second.done || third.done) throw new Error("expected tables"); + expect(second.project.id).not.toBe(first.project.id); + expect(third.queueId).toBe(second.queueId); + + const open = await db + .select() + .from(judgeQueue) + .where(and(eq(judgeQueue.judgeId, judgeIds[0]!), eq(judgeQueue.isCompleted, false))); + expect(open.map((r) => r.id)).toEqual([second.queueId]); + }); + it("clears a list built in advance by the old assignment instead of treating it as visits", async () => { const { hackathonId, projectIds, judgeIds } = await seed({ projects: 3, judges: 1 }); await db.insert(judgeQueue).values( diff --git a/packages/api/src/routers/judge/dispatch.ts b/packages/api/src/routers/judge/dispatch.ts index 1eab7c70..810536ea 100644 --- a/packages/api/src/routers/judge/dispatch.ts +++ b/packages/api/src/routers/judge/dispatch.ts @@ -156,15 +156,11 @@ export type DispatchResult = }; /** - * Projects this judge is eligible for (only the columns routing needs), and - * the group they judge in. Queries run one after another: inside a - * transaction they share one connection, which cannot run two at once. + * Each judge's track label and the coverage group it puts them in. Queries + * run one after another: inside a transaction they share one connection, + * which cannot run two at once. */ -export async function loadPool( - db: DrizzleDB, - judgeId: string, - hackathonId: string, -) { +export async function loadGroups(db: DrizzleDB, hackathonId: string) { const hackathon = await db.query.hackathons.findFirst({ where: eq(hackathons.id, hackathonId), columns: { tracks: true }, @@ -173,6 +169,29 @@ export async function loadPool( .select({ judgeId: judgeAssignments.judgeId, track: judgeAssignments.track }) .from(judgeAssignments) .where(eq(judgeAssignments.hackathonId, hackathonId)); + + const mainTracks = new Set( + hackathon?.tracks?.length + ? hackathon.tracks + : assignments.map((a) => a.track).filter((t): t is string => !!t), + ); + const trackByJudge = new Map(assignments.map((a) => [a.judgeId, a.track])); + const groupByJudge = (id: string) => + groupOf(trackByJudge.get(id) ?? null, mainTracks); + + return { trackByJudge, groupByJudge }; +} + +/** + * Projects this judge is eligible for (only the columns routing needs), and + * the group they judge in. + */ +export async function loadPool( + db: DrizzleDB, + judgeId: string, + hackathonId: string, +) { + const { trackByJudge, groupByJudge } = await loadGroups(db, hackathonId); const projects = await db .select({ id: judgingProjects.id, @@ -189,19 +208,11 @@ export async function loadPool( ), ); - const mainTracks = new Set( - hackathon?.tracks?.length - ? hackathon.tracks - : assignments.map((a) => a.track).filter((t): t is string => !!t), - ); - const trackByJudge = new Map(assignments.map((a) => [a.judgeId, a.track])); const myTrack = trackByJudge.get(judgeId) ?? null; - const groupByJudge = (id: string) => - groupOf(trackByJudge.get(id) ?? null, mainTracks); return { pool: projects.filter((p) => projectMatchesTrack(p, myTrack)), - myGroup: groupOf(myTrack, mainTracks), + myGroup: groupByJudge(judgeId), groupByJudge, }; } @@ -250,11 +261,18 @@ export async function dispatchNext( // the pool for someone else, and not to this judge again. A row that was // never handed out at all is a list an earlier version built in advance: // it is not a visit, so it goes rather than blocking that project. + // A live visit to a project withdrawn since closes the same way. Left + // open, it would keep matching as this judge's live visit and send every + // later dispatch past it to claim yet another table. + const poolById = new Map(pool.map((p) => [p.id, p])); const unopened = mine.filter( (r) => !r.isCompleted && !r.startedAt && !r.arrivedAt, ); const lapsed = mine.filter( - (r) => !r.isCompleted && (r.startedAt || r.arrivedAt) && !isLive(r, now), + (r) => + !r.isCompleted && + (r.startedAt || r.arrivedAt) && + (!isLive(r, now) || !poolById.has(r.projectId)), ); if (unopened.length > 0) { await tx.delete(judgeQueue).where( @@ -277,10 +295,8 @@ export async function dispatchNext( } const visits = mine.filter((r) => !unopened.includes(r)); - const poolById = new Map(pool.map((p) => [p.id, p])); - const live = visits.find((r) => isLive(r, now)); - // A live visit to a project withdrawn since is dropped like any other. - if (live && poolById.has(live.projectId)) { + const live = visits.find((r) => !lapsed.includes(r) && isLive(r, now)); + if (live) { return { done: false, queueId: live.id, From f60f5134c2509506190792dd1ecd4297eb60a785 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sat, 3 Oct 2026 21:00:25 -0400 Subject: [PATCH 3/3] ci: single verify-then-deploy pipeline One ci.yml lints, typechecks, tests and builds every PR and push to main/dev; Hacklytics preview and live deploys run only after it passes and ship that build. Replaces pnpm-ci, test, and the two Firebase Hosting workflows, drops the disabled deploy copy and the static CodeQL summary job, and points the labeler at pnpm-lock.yaml. --- .github/labeler.yml | 2 +- .github/workflows/ci.yml | 109 ++++++++++++++++++ .github/workflows/codeql.yml | 13 --- .../workflows/deploy-hacklytics.yml.disabled | 44 ------- .github/workflows/firebase-hosting-merge.yml | 40 ------- .../firebase-hosting-pull-request.yml | 40 ------- .github/workflows/pnpm-ci.yml | 28 ----- .github/workflows/test.yml | 36 ------ docs/operations/ci-cd.md | 26 ++--- docs/operations/testing.md | 4 +- docs/sites/hacklytics2027.md | 6 +- 11 files changed, 126 insertions(+), 222 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/deploy-hacklytics.yml.disabled delete mode 100644 .github/workflows/firebase-hosting-merge.yml delete mode 100644 .github/workflows/firebase-hosting-pull-request.yml delete mode 100644 .github/workflows/pnpm-ci.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/labeler.yml b/.github/labeler.yml index 266e9e49..a2a9454b 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -8,4 +8,4 @@ documentation: - head-branch: ["^doc/", "^docs/"] dependencies: - changed-files: - - any-glob-to-any-file: ["package.json", "package-lock.json", "bun.lockb"] + - any-glob-to-any-file: ["**/package.json", "pnpm-lock.yaml"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d0d58879 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI/CD + +# One pipeline: every PR and every push to main/dev is linted, typechecked, +# tested and built once. Hacklytics deploys only from that verified build — +# a preview channel per PR, live on main. mainweb is not deployed here: +# Firebase App Hosting builds it from main itself (apphosting.yaml). +on: + push: + branches: ["main", "dev"] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + verify: + name: Lint, typecheck, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # No version pin: the one in packageManager is the one that reads + # overrides and allowBuilds from pnpm-workspace.yaml. Pinning here + # silently installed an older pnpm that ignores both. + - uses: pnpm/action-setup@v5 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint and typecheck + run: pnpm turbo run lint typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm turbo run build + + - name: Upload Hacklytics build + uses: actions/upload-artifact@v4 + with: + name: hacklytics-out + path: sites/hacklytics2027/out + retention-days: 3 + + preview-hacklytics: + name: Preview Hacklytics + needs: verify + # Forks and Dependabot get no repo secrets, so the deploy would fail on a + # missing service account. The verify job above still checks their build. + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + checks: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/download-artifact@v4 + with: + name: hacklytics-out + path: sites/hacklytics2027/out + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DSGT_WEBSITE }} + target: hacklytics + projectId: dsgt-website + channelId: pr-${{ github.event.pull_request.number }} + entryPoint: "." + + deploy-hacklytics: + name: Deploy Hacklytics + needs: verify + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + checks: write + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: actions/download-artifact@v4 + with: + name: hacklytics-out + path: sites/hacklytics2027/out + + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DSGT_WEBSITE }} + target: hacklytics + projectId: dsgt-website + channelId: live + entryPoint: "." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d5f6a69a..a5971eb5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -83,16 +83,3 @@ jobs: comment-summary-in-pr: always vulnerability-check: true - security-summary: - name: Security Analysis Summary - needs: [analyze] - runs-on: ubuntu-latest - if: always() - - steps: - - name: Generate Summary - run: | - echo "# Security Analysis Complete 🛡️" >> $GITHUB_STEP_SUMMARY - echo "The advanced CodeQL analysis and Dependency Review have finished." >> $GITHUB_STEP_SUMMARY - echo "All findings have been uploaded directly to the GitHub Security tab." >> $GITHUB_STEP_SUMMARY - echo "View detailed results at: https://github.com/${{ github.repository }}/security/code-scanning" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/deploy-hacklytics.yml.disabled b/.github/workflows/deploy-hacklytics.yml.disabled deleted file mode 100644 index e16f313e..00000000 --- a/.github/workflows/deploy-hacklytics.yml.disabled +++ /dev/null @@ -1,44 +0,0 @@ -name: Deploy Hacklytics to Firebase Hosting - -on: - push: - branches: - - main - pull_request: - -permissions: - contents: read - pull-requests: write - checks: write - -jobs: - build_and_deploy: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: 20 - - - name: Install Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Build - run: bun turbo build --filter=hacklytics2026 - - - name: Deploy to Firebase Hosting - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - # repoToken is removed to prevent PR comments - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DSGT }} - projectId: dsgt-website - channelId: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'live' }} - target: hacklytics-2 diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml deleted file mode 100644 index 1b8638da..00000000 --- a/.github/workflows/firebase-hosting-merge.yml +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Deploy to Firebase Hosting on merge -on: - push: - branches: - - main -# Least privilege instead of the repository default token scope. -permissions: - checks: write - contents: read - pull-requests: write -jobs: - build_and_deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v5 - - - uses: actions/setup-node@v6 - with: - node-version: "22.23.1" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install - - - name: Build Hacklytics 2027 - run: pnpm turbo run build --filter=hacklytics2027 - - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DSGT_WEBSITE }} - target: hacklytics - channelId: live - projectId: dsgt-website - entryPoint: "." diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml deleted file mode 100644 index cca02e3a..00000000 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ /dev/null @@ -1,40 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Deploy to Firebase Hosting on PR -on: pull_request -permissions: - checks: write - contents: read - pull-requests: write -jobs: - build_and_preview: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v5 - - - uses: actions/setup-node@v6 - with: - node-version: "22.23.1" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install - - - name: Build Hacklytics 2027 - run: pnpm turbo run build --filter=hacklytics2027 - - # Dependabot PRs get no repo secrets, so the deploy would fail on a - # missing service account. The build above still checks the bump. - - uses: FirebaseExtended/action-hosting-deploy@v0 - if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }} - with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DSGT_WEBSITE }} - target: hacklytics - projectId: dsgt-website - channelId: pr-${{ github.event.pull_request.number }} - entryPoint: "." diff --git a/.github/workflows/pnpm-ci.yml b/.github/workflows/pnpm-ci.yml deleted file mode 100644 index 968e475b..00000000 --- a/.github/workflows/pnpm-ci.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Pnpm CI - -on: - push: - branches: ["main", "dev"] - pull_request: - branches: ["*"] - -jobs: - build: - permissions: - contents: read - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v5 - - - uses: actions/setup-node@v6 - with: - node-version: "22" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install - - - name: Build - run: pnpm turbo run build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 1167fadd..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Run Tests - -on: - push: - branches: - - main - - dev - pull_request: - -# Read-only: tests need nothing more than the checkout. -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Setup pnpm - # No version pin: the one in packageManager is the one that reads - # overrides and allowBuilds from pnpm-workspace.yaml. Pinning here - # silently installed an older pnpm that ignores both. - uses: pnpm/action-setup@v5 - - - name: Install dependencies - run: pnpm install - - - name: Run unique test cases - run: pnpm test diff --git a/docs/operations/ci-cd.md b/docs/operations/ci-cd.md index 2377051d..f3342917 100644 --- a/docs/operations/ci-cd.md +++ b/docs/operations/ci-cd.md @@ -2,25 +2,25 @@ All workflows live in `.github/workflows/`. -## Quality +## Pipeline -| Workflow | Trigger | What it does | -| --- | --- | --- | -| `pnpm-ci.yml` | Push `main`/`dev`, PRs | `pnpm install` + `pnpm turbo run build` (Node 22) | -| `test.yml` | Push `main`/`dev`, PRs | `pnpm test` (Node 22; pnpm comes from `packageManager`, unpinned in the workflow so it cannot drift) | -| `codeql.yml` | Push/PR `main`/`dev`, daily 02:00 UTC | CodeQL `security-extended,security-and-quality`; PRs also run dependency review (`fail-on-severity: high`) | +`ci.yml` is the one CI/CD pipeline, on every PR and every push to `main`/`dev`: + +1. `verify` — `pnpm install --frozen-lockfile`, then `turbo run lint typecheck`, `pnpm test`, `turbo run build` (Node from `.nvmrc`; pnpm from `packageManager`, unpinned in the workflow so it cannot drift). Uploads `sites/hacklytics2027/out`. +2. `preview-hacklytics` — PRs from this repo (not forks, not Dependabot): deploys that build to Firebase Hosting channel `pr-N`. +3. `deploy-hacklytics` — push to `main` only: deploys the same build to the live `hacklytics` target. -## Deploy +Both deploy jobs `need` `verify`, so nothing ships that failed lint, typecheck, tests, or build. -| Workflow | Trigger | Target | +## Security + +| Workflow | Trigger | What it does | | --- | --- | --- | -| `deploy-hacklytics.yml` | Push `main` and PRs | Firebase Hosting `hacklytics` (live vs `pr-N`) | -| `firebase-hosting-merge.yml` | Push `main` | Same live Hacklytics deploy | -| `firebase-hosting-pull-request.yml` | PRs (same-repo only) | Hacklytics preview channel | +| `codeql.yml` | Push/PR `main`/`dev`, daily 02:00 UTC | CodeQL `security-extended,security-and-quality`; PRs also run dependency review (`fail-on-severity: high`) | -Mainweb production is **Firebase App Hosting**, not these Hosting workflows. App Hosting builds from `apphosting.yaml` when the connected branch updates. +## Mainweb deploy -`deploy-hacklytics.yml.disabled` is a leftover disabled copy. +Mainweb production is **Firebase App Hosting**, not `ci.yml`. App Hosting builds from `apphosting.yaml` when the connected branch updates, independent of whether `verify` passed — the gate is the `main` ruleset, so require the `Lint, typecheck, test, build` check there. ## Branch automation diff --git a/docs/operations/testing.md b/docs/operations/testing.md index e750c5df..d115b462 100644 --- a/docs/operations/testing.md +++ b/docs/operations/testing.md @@ -12,7 +12,7 @@ That is: vitest run packages/api packages/db sites/mainweb/lib ``` -CI: `.github/workflows/test.yml` on pushes to `main`/`dev` and on pull requests. +CI: the `verify` job in `.github/workflows/ci.yml` on pushes to `main`/`dev` and on pull requests. ## `@query/api` @@ -55,7 +55,7 @@ Playwright (`@playwright/test`). `scripts/capture-vision.ts` is a visual capture ## What “green” means -`pnpm test` does **not** run `pnpm lint`, `pnpm typecheck`, or `pnpm build`. CI build is a separate workflow (`pnpm-ci.yml`). Run all four before a release-quality PR: +`pnpm test` does **not** run `pnpm lint`, `pnpm typecheck`, or `pnpm build`. CI runs all four in the `verify` job of `ci.yml`. Run all four before a release-quality PR: ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm build diff --git a/docs/sites/hacklytics2027.md b/docs/sites/hacklytics2027.md index 6ef15d56..ec29f8ec 100644 --- a/docs/sites/hacklytics2027.md +++ b/docs/sites/hacklytics2027.md @@ -46,11 +46,7 @@ Interest requires a portal account (verified email). The CTA is: Firebase Hosting target `hacklytics`, public dir `sites/hacklytics2027/out` (`firebase.json`). -Workflows: - -- `.github/workflows/deploy-hacklytics.yml` — build filter `hacklytics2027`, deploy target `hacklytics` (live on `main`, preview channel `pr-N` on PRs) -- `.github/workflows/firebase-hosting-merge.yml` — same live deploy on `main` -- `.github/workflows/firebase-hosting-pull-request.yml` — PR preview channels +Workflow: `.github/workflows/ci.yml`. The `verify` job builds every site; `deploy-hacklytics` ships that build live on `main` and `preview-hacklytics` to channel `pr-N` on PRs. Neither runs unless `verify` passes. Asset caching: hashed JS/CSS/fonts/images `max-age=31536000, immutable`; HTML `max-age=3600`. See `firebase.json`.