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.