Phase 7: judges — links, phone page, open link, judged control room

- Setup: nominate judges, copy personal links, QR codes (generated on
  demand in the browser), revoke/restore, and an open-link toggle.
- GET /api/judge/[token] returns only that judge's own scores; PUT
  .../scores runs every check from ARCHITECTURE.md in checkJudgeWrite
  (pure, unit-tested) after one parallel lookup, plus a rate limit.
- /j/[token]: mobile cards per team, spotlight pinned first, autosave
  with per-card status, read-only when locked/done, link-off when revoked.
- /jo/[token]: name form -> personal link, remembered in localStorage so
  a re-scan doesn't create a second judge.
- Control room: averages with on-request overrides, judge progress
  matrix, per-judge breakdown, spotlight select and lock toggle.
- usePendingEdits shares the optimistic autosave logic between the admin
  grid and the judge page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:29:33 +02:00
co-authored by Claude Opus 5.5
parent 1410c0fa13
commit 2702491096
25 changed files with 1737 additions and 85 deletions
+2 -2
View File
@@ -30,12 +30,12 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa
- Public/judge access = token in URL → route handler validates with zod → service-role query scoped by that token.
- Judge write checks: judge not revoked, game not locked/done, criterion belongs to judge's game, team belongs to the game's event, value within min/max and on step. (DB trigger also enforces range.)
- Scoreboard API checks `scoreboard_visibility` and strips scores when `hidden`; returns `frozen_snapshot` when `frozen`.
- Light rate limiting on judge PUT (e.g. 20 req/s per token, in-memory is fine for v1).
- Light rate limiting on judge PUT (20 req/s per token) and open-link join (3 req/s per link), in-memory per server instance. An open link can create at most 50 judges per game.
## Realtime (Broadcast "ping then refetch")
Channels (names use tokens/ids; payload is always just `{ type: "refresh", scope }`):
- `sb:{scoreboardToken}` — scoreboard listens
- `game:{gameId}` — all judges of that game listen
- `game:{gameId}` — all judges of that game listen (pinged for lock, spotlight, revoke and game edits — not for other judges' scores, which they never see)
- `admin:{eventId}` — admin control room listens (judge submissions land here)
`src/lib/realtime.ts` exposes `notify({ eventId, gameId? })` which looks up the scoreboard token and sends to the right channels from the server.