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`. 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..1a11b025 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, loadGroups, 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,138 @@ 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) ?? []; + d ? Math.floor(secondsSince(d) / 60) : null; - // 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; - - 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; + .filter((d): d is number => typeof d === "number" && d > 0) + .sort((a, b) => a - b); - const idleMinutes = minutesSince(lastVoteAt); - - 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 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 mainVotes) { + 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 = mainVotes.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..de11abce --- /dev/null +++ b/packages/api/src/routers/judge/dispatch.db.test.ts @@ -0,0 +1,318 @@ +/** + * 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("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( + 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..810536ea --- /dev/null +++ b/packages/api/src/routers/judge/dispatch.ts @@ -0,0 +1,387 @@ +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; + }; + +/** + * 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 loadGroups(db: DrizzleDB, 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 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, + tableNumber: judgingProjects.tableNumber, + tracks: judgingProjects.tracks, + challenges: judgingProjects.challenges, + isCreateX: judgingProjects.isCreateX, + }) + .from(judgingProjects) + .where( + and( + eq(judgingProjects.hackathonId, hackathonId), + isNull(judgingProjects.withdrawnAt), + ), + ); + + const myTrack = trackByJudge.get(judgeId) ?? null; + + return { + pool: projects.filter((p) => projectMatchesTrack(p, myTrack)), + myGroup: groupByJudge(judgeId), + 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. + // 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) || !poolById.has(r.projectId)), + ); + 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 live = visits.find((r) => !lapsed.includes(r) && isLive(r, now)); + if (live) { + 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 8029c21b..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…"} @@ -512,7 +539,7 @@ export default function JudgeHackathonPage() {

Scan the team's code to begin

-

+

Walk to table {project.tableNumber ?? "?"} and tap your phone on the NFC tag, or scan the card on their desk. Your judging time starts from then, not from when this table was assigned @@ -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" + ] }