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.
+275 -4
View File
@@ -11,6 +11,7 @@
"@supabase/ssr": "^0.12.7",
"@supabase/supabase-js": "^2.117.2",
"next": "16.3.8",
"qrcode": "^1.5.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
@@ -19,6 +20,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^22",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^5.0.3",
@@ -2073,6 +2075,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@@ -2852,11 +2864,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -3264,6 +3284,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001814",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz",
@@ -3317,11 +3346,21 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3334,7 +3373,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
@@ -3465,6 +3503,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3518,6 +3565,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4451,6 +4504,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4956,6 +5018,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -6116,6 +6187,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6133,7 +6213,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6175,6 +6254,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -6246,6 +6334,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6339,6 +6444,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -6530,6 +6650,12 @@
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6778,6 +6904,26 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -6892,6 +7038,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -7909,6 +8067,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.24",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz",
@@ -7954,6 +8118,26 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -7961,6 +8145,93 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -17,6 +17,7 @@
"@supabase/ssr": "^0.12.7",
"@supabase/supabase-js": "^2.117.2",
"next": "16.3.8",
"qrcode": "^1.5.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
@@ -25,6 +26,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^22",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^5.0.3",
+105
View File
@@ -0,0 +1,105 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/auth";
import { judgeNameSchema } from "@/lib/judge";
import { notify } from "@/lib/realtime";
import { idSchema } from "@/lib/tokens";
import type { ActionResult } from "./actions";
// Server actions for managing judges (setup page) and running a judged game (control room).
const ok: ActionResult = { ok: true };
const fail = (error: string): ActionResult => ({ ok: false, error });
const dbFail = (what: string, message: string) => fail(`${what} failed: ${message}. Try again.`);
const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success);
const BAD_ID = fail("That item no longer exists. Reload the page.");
function revalidate(eventId: string) {
revalidatePath(`/admin/events/${eventId}`);
revalidatePath(`/admin/events/${eventId}/live`);
}
/** Confirms the game is in this event (RLS already confirms the event is yours). */
async function gameInEvent(eventId: string, gameId: string) {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return { supabase, found: false };
const { data } = await supabase.from("games").select("id").eq("id", gameId).eq("event_id", eventId).maybeSingle();
return { supabase, found: data !== null };
}
/** Nominates a judge by name; the database gives them a personal link token. */
export async function addJudge(eventId: string, gameId: string, name: string): Promise<ActionResult> {
const { supabase, found } = await gameInEvent(eventId, gameId);
if (!found) return BAD_ID;
const parsed = judgeNameSchema.safeParse({ name });
if (!parsed.success) return fail(parsed.error.issues[0].message.replace("your name", "the judge's name").replace("Your name", "The name"));
const { error } = await supabase
.from("judges")
.insert({ game_id: gameId, name: parsed.data.name, source: "nominated" });
if (error) return dbFail("Adding the judge", error.message);
revalidate(eventId);
await notify({ eventId });
return ok;
}
/** Revoking stops the link working and excludes the judge's scores; restoring brings both back. */
export async function setJudgeRevoked(
eventId: string,
gameId: string,
judgeId: string,
revoked: boolean,
): Promise<ActionResult> {
const { supabase, found } = await gameInEvent(eventId, gameId);
if (!found || !validIds(judgeId)) return BAD_ID;
const { error } = await supabase
.from("judges")
.update({ revoked })
.eq("id", judgeId)
.eq("game_id", gameId);
if (error) return dbFail("Updating the judge", error.message);
revalidate(eventId);
// gameId: the revoked judge's own page must find out too.
await notify({ eventId, gameId });
return ok;
}
type GameFlags = { open_link_enabled?: boolean; judging_locked?: boolean; spotlight_team_id?: string | null };
async function updateGame(eventId: string, gameId: string, what: string, patch: GameFlags) {
const { supabase, found } = await gameInEvent(eventId, gameId);
if (!found) return BAD_ID;
const { error } = await supabase.from("games").update(patch).eq("id", gameId).eq("event_id", eventId);
if (error) return dbFail(what, error.message);
revalidate(eventId);
await notify({ eventId, gameId });
return ok;
}
export async function setOpenLink(eventId: string, gameId: string, enabled: boolean): Promise<ActionResult> {
return updateGame(eventId, gameId, "Updating the open link", { open_link_enabled: enabled === true });
}
/** Locked = judges can still see their scores but can't change them. */
export async function setJudgingLocked(eventId: string, gameId: string, locked: boolean): Promise<ActionResult> {
return updateGame(eventId, gameId, "Updating the lock", { judging_locked: locked === true });
}
/** The "now performing" team, pinned and highlighted on every judge's phone. */
export async function setSpotlight(eventId: string, gameId: string, teamId: string | null): Promise<ActionResult> {
if (teamId !== null) {
if (!validIds(teamId)) return BAD_ID;
const { supabase } = await requireAdmin();
const { data } = await supabase.from("teams").select("id").eq("id", teamId).eq("event_id", eventId).maybeSingle();
if (!data) return BAD_ID;
}
return updateGame(eventId, gameId, "Setting the spotlight", { spotlight_team_id: teamId });
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { judgeNameSchema } from "@/lib/judge";
import { joinViaOpenLink } from "@/lib/judge.server";
import { createRateLimiter } from "@/lib/rate-limit";
const headers = { "Cache-Control": "no-store" };
const allow = createRateLimiter(3);
/** `{ name }` → `{ judgeToken }`: turns a visitor of the open link into a judge. */
export async function POST(request: Request, context: RouteContext<"/api/judge-open/[openToken]">) {
const { openToken } = await context.params;
if (!allow(openToken)) {
return NextResponse.json(
{ error: "Lots of people are joining at once. Wait a second and try again." },
{ status: 429, headers },
);
}
const body = judgeNameSchema.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: body.error.issues[0].message }, { status: 400, headers });
}
try {
const result = await joinViaOpenLink(openToken, body.data.name);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers });
return NextResponse.json({ judgeToken: result.judgeToken }, { headers });
} catch (error) {
console.error("judge open-link join failed", error);
return NextResponse.json({ error: "Couldn't join. Try again." }, { status: 500, headers });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getJudgePayload } from "@/lib/judge.server";
const headers = { "Cache-Control": "no-store" };
export async function GET(_request: Request, context: RouteContext<"/api/judge/[token]">) {
const { token } = await context.params;
try {
const result = await getJudgePayload(token);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers });
return NextResponse.json(result.payload, { headers });
} catch (error) {
console.error("judge API failed", error);
return NextResponse.json({ error: "Could not load the game. Try again." }, { status: 500, headers });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { judgeScoreSchema } from "@/lib/judge";
import { saveJudgeScore } from "@/lib/judge.server";
import { createRateLimiter } from "@/lib/rate-limit";
const headers = { "Cache-Control": "no-store" };
// Far above what a person can tap, low enough to stop a stuck client or a script.
const allow = createRateLimiter(20);
/** Upserts one of this judge's scores: `{ teamId, criterionId, value }`; `value: null` clears it. */
export async function PUT(request: Request, context: RouteContext<"/api/judge/[token]/scores">) {
const { token } = await context.params;
if (!allow(token)) {
return NextResponse.json(
{ error: "Too many taps at once. Wait a second and try again." },
{ status: 429, headers },
);
}
const body = judgeScoreSchema.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: "That score couldn't be read. Reload the page." }, { status: 400, headers });
}
try {
const result = await saveJudgeScore(token, body.data);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers });
return NextResponse.json({ ok: true }, { headers });
} catch (error) {
console.error("judge score save failed", error);
return NextResponse.json({ error: "That score wasn't saved. Try again." }, { status: 500, headers });
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { JudgeMessage, JudgeView } from "@/components/judge/judge-view";
import { getJudgePayload } from "@/lib/judge.server";
export const metadata: Metadata = {
title: "Judge",
// The link is the judge's only credential; keep it out of search engines.
robots: { index: false, follow: false },
};
export default async function JudgePage(props: PageProps<"/j/[token]">) {
const { token } = await props.params;
const result = await getJudgePayload(token);
if (!result.ok) return <JudgeMessage>{result.error}</JudgeMessage>;
return <JudgeView token={token} initial={result.payload} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { JudgeMessage } from "@/components/judge/judge-view";
import { OpenLinkForm } from "@/components/judge/open-link-form";
import { getOpenLinkGame } from "@/lib/judge.server";
export const metadata: Metadata = {
title: "Join as a judge",
robots: { index: false, follow: false },
};
export default async function OpenJudgePage(props: PageProps<"/jo/[openToken]">) {
const { openToken } = await props.params;
const game = await getOpenLinkGame(openToken);
if (!game.ok) return <JudgeMessage>{game.error}</JudgeMessage>;
return <OpenLinkForm openToken={openToken} gameTitle={game.gameTitle} />;
}
+4
View File
@@ -13,6 +13,7 @@ import { channelNames } from "@/lib/realtime-channels";
import { BonusPanel } from "./bonus-panel";
import { ErrorText } from "./error-text";
import { JudgedPanel } from "./judged-panel";
import { ScoreboardPanel, VISIBILITY_ORDER } from "./scoreboard-panel";
import { ScoringGrid } from "./scoring-grid";
import { useAction } from "./use-action";
@@ -188,6 +189,9 @@ export function ControlRoom({ view }: { view: LiveView }) {
<>
{/* Keyed by game so half-saved edits never carry over to another game. */}
<ScoringGrid key={currentGame.id} eventId={event.id} game={currentGame} teams={teams} />
{currentGame.scoringMode === "judged" && (
<JudgedPanel eventId={event.id} game={currentGame} teams={teams} />
)}
{currentGame.adminNotes && (
<div className="rounded-md bg-chalk p-3">
<h3 className="text-sm font-bold">Notes (only you see these)</h3>
+2
View File
@@ -10,6 +10,7 @@ import type { GameWithCriteria } from "@/lib/queries/event";
import { ErrorText } from "./error-text";
import { GameForm } from "./game-form";
import { JudgesEditor } from "./judges-editor";
import { useAction } from "./use-action";
const presetKeys = Object.keys(criteriaPresets) as PresetKey[];
@@ -131,6 +132,7 @@ function GameCard({
{/* Re-mount after each save so the form picks up ids of newly created criteria. */}
<GameForm key={game.updated_at} eventId={eventId} game={game} />
{game.scoring_mode === "judged" && <JudgesEditor eventId={eventId} game={game} />}
</div>
</details>
</li>
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { setJudgingLocked, setSpotlight } from "@/app/admin/judge-actions";
import { Button } from "@/components/ui/button";
import { inputClass } from "@/components/ui/field";
import { formatScore } from "@/lib/criteria";
import type { LiveGame, LiveView } from "@/lib/live-view";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
/** Control-room extras for a judged game: spotlight, lock, who has scored what. */
export function JudgedPanel({
eventId,
game,
teams,
}: {
eventId: string;
game: LiveGame;
teams: LiveView["teams"];
}) {
const { pending, error, run } = useAction();
const judges = game.judges.filter((judge) => !judge.revoked);
const revokedCount = game.judges.length - judges.length;
const criteriaCount = game.criteria.length;
const scoredCount = (judge: LiveGame["judges"][number], teamId: string) =>
game.criteria.filter((criterion) => `${teamId}|${criterion.id}` in judge.values).length;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1">
<span className="text-sm font-bold">Spotlight (now performing)</span>
<select
value={game.spotlightTeamId ?? ""}
disabled={pending}
onChange={(event) => run(() => setSpotlight(eventId, game.id, event.target.value || null))}
className={`${inputClass} w-auto min-w-48`}
>
<option value="">No one</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</label>
<Button
variant={game.judgingLocked ? "primary" : "secondary"}
aria-pressed={game.judgingLocked}
disabled={pending}
onClick={() => run(() => setJudgingLocked(eventId, game.id, !game.judgingLocked))}
>
{game.judgingLocked ? "Judging locked — unlock" : "Lock judging"}
</Button>
{game.judgingLocked && <span className="pb-2">Judges can see their scores but not change them.</span>}
</div>
<ErrorText>{error}</ErrorText>
<div>
<h3 className="font-bold">Judge progress</h3>
{judges.length === 0 ? (
<p>No judges yet. Add judges or turn on the open link on the setup page.</p>
) : (
<div className="overflow-x-auto">
<table className="mt-1 border-separate border-spacing-x-3 border-spacing-y-1 text-left">
<thead>
<tr className="text-sm">
<th scope="col" className="font-bold">
Judge
</th>
{teams.map((team) => (
<th key={team.id} scope="col" className="font-bold">
{team.name}
</th>
))}
</tr>
</thead>
<tbody>
{judges.map((judge) => (
<tr key={judge.id}>
<th scope="row" className="font-normal whitespace-nowrap">
{judge.name}
</th>
{teams.map((team) => {
const scored = scoredCount(judge, team.id);
const done = criteriaCount > 0 && scored === criteriaCount;
return (
<td key={team.id} className="tabular-nums">
{done ? (
<span className="font-bold text-ink">
✓<span className="sr-only"> all scored</span>
</span>
) : scored === 0 ? (
<span className="text-ink/50">
✗<span className="sr-only"> nothing scored</span>
</span>
) : (
<span>
{scored}/{criteriaCount}
</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{revokedCount > 0 && (
<p className="text-sm text-ink/70">
{revokedCount} revoked {revokedCount === 1 ? "judge is" : "judges are"} not counted.
</p>
)}
</div>
{judges.length > 0 && (
<details>
<summary className="min-h-11 cursor-pointer py-2 font-bold">Every judge&apos;s scores</summary>
<div className="flex flex-col gap-3">
{teams.map((team) => (
<div key={team.id} className="overflow-x-auto">
<table className="border-separate border-spacing-x-3 text-left">
<caption className="text-left font-bold">{team.name}</caption>
<thead>
<tr className="text-sm">
<th scope="col" className="font-bold">
Judge
</th>
{game.criteria.map((criterion) => (
<th key={criterion.id} scope="col" className="font-bold">
{criterion.label}
</th>
))}
</tr>
</thead>
<tbody>
{judges.map((judge) => (
<tr key={judge.id}>
<th scope="row" className="font-normal whitespace-nowrap">
{judge.name}
</th>
{game.criteria.map((criterion) => {
const value = judge.values[`${team.id}|${criterion.id}`];
return (
<td key={criterion.id} className="tabular-nums">
{value === undefined ? "–" : formatScore(value)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
</details>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { addJudge, setJudgeRevoked, setOpenLink } from "@/app/admin/judge-actions";
import { Button } from "@/components/ui/button";
import { inputClass } from "@/components/ui/field";
import { publicEnv } from "@/lib/env.public";
import type { GameWithCriteria } from "@/lib/queries/event";
import { ErrorText } from "./error-text";
import { ShareLink } from "./share-link";
import { useAction } from "./use-action";
const judgeUrl = (token: string) => `${publicEnv.NEXT_PUBLIC_SITE_URL}/j/${token}`;
const openUrl = (token: string) => `${publicEnv.NEXT_PUBLIC_SITE_URL}/jo/${token}`;
/** Judges for one judged game: personal links, the shared open link, revoking. */
export function JudgesEditor({ eventId, game }: { eventId: string; game: GameWithCriteria }) {
const [name, setName] = useState("");
const { pending, error, run } = useAction();
return (
<section className="flex flex-col gap-3 rounded-md border border-ink/15 bg-chalk p-3">
<h3 className="text-lg font-bold">Judges</h3>
{game.judges.length === 0 ? (
<p>No judges yet. Add them by name below, or turn on the open link.</p>
) : (
<ul className="flex flex-col gap-2">
{game.judges.map((judge) => (
<li key={judge.id} className="flex flex-wrap items-center gap-2 rounded-md bg-white p-2">
<span className={`min-w-32 flex-1 font-bold ${judge.revoked ? "text-ink/50 line-through" : ""}`}>
{judge.name}
{judge.source === "open_link" && (
<span className="ml-2 text-sm font-normal text-ink/70 no-underline">joined via open link</span>
)}
</span>
{judge.revoked ? (
<span className="text-sm">Revoked — link off, scores not counted</span>
) : (
<ShareLink
url={judgeUrl(judge.token)}
copyLabel="Copy judge link"
qrLabel={`QR code for ${judge.name}'s judge link`}
/>
)}
<Button
variant={judge.revoked ? "secondary" : "danger"}
size="sm"
disabled={pending}
onClick={() => run(() => setJudgeRevoked(eventId, game.id, judge.id, !judge.revoked))}
>
{judge.revoked ? "Restore" : "Revoke"}
</Button>
</li>
))}
</ul>
)}
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
run(() => addJudge(eventId, game.id, name), () => setName(""));
}}
>
<input
aria-label="New judge's name"
value={name}
onChange={(event) => setName(event.target.value)}
required
maxLength={60}
placeholder="Judge's name"
className={`${inputClass} max-w-xs`}
/>
<Button type="submit" variant="secondary" disabled={pending}>
Add judge
</Button>
</form>
<div className="flex flex-wrap items-center gap-2 border-t border-ink/15 pt-3">
<div className="min-w-48 flex-1">
<p className="font-bold">Open link: {game.open_link_enabled ? "on" : "off"}</p>
<p className="text-sm text-ink/70">
One link for everyone: whoever opens it types their name and becomes a judge.
</p>
</div>
{game.open_link_enabled && (
<ShareLink
url={openUrl(game.open_token)}
copyLabel="Copy open link"
qrLabel={`QR code for the open judge link of ${game.title}`}
/>
)}
<Button
variant="secondary"
size="sm"
aria-pressed={game.open_link_enabled}
disabled={pending}
onClick={() => run(() => setOpenLink(eventId, game.id, !game.open_link_enabled))}
>
{game.open_link_enabled ? "Turn off" : "Turn on"}
</Button>
</div>
<ErrorText>{error}</ErrorText>
</section>
);
}
+69 -76
View File
@@ -1,23 +1,18 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { saveAdminScore } from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
import { BlockInput, NumberStepper } from "@/components/ui/score-inputs";
import { formatScore } from "@/lib/criteria";
import { usePendingEdits } from "@/lib/hooks/use-pending-edits";
import type { LiveGame, LiveView } from "@/lib/live-view";
import { ErrorText } from "./error-text";
// An edit the server hasn't echoed back yet. Shown instead of the server's value so
// taps feel instant (optimistic UI).
type PendingEdit = { value: number | null; version: number; savedAt: number | null };
const cellKey = (teamId: string, criterionId: string) => `${teamId}|${criterionId}`;
// A saved edit normally disappears when the refreshed data matches it. If it never
// matches (someone else changed the cell), stop masking the server's value after this long.
const STALE_MS = 5000;
export function ScoringGrid({
eventId,
@@ -29,59 +24,30 @@ export function ScoringGrid({
teams: LiveView["teams"];
}) {
const router = useRouter();
const [pending, setPending] = useState<Record<string, PendingEdit>>({});
const [savingCount, setSavingCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const versions = useRef(0);
// Drop optimistic edits once the server data has caught up with them.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reconciling local edits with fresh server props
setPending((current) => {
const now = Date.now();
const kept = Object.entries(current).filter(([key, edit]) => {
if (edit.savedAt === null) return true;
const [teamId, criterionId] = key.split("|");
const serverValue = game.cells[teamId]?.[criterionId]?.adminValue ?? null;
return serverValue !== edit.value && now - edit.savedAt < STALE_MS;
});
return kept.length === Object.keys(current).length ? current : Object.fromEntries(kept);
});
}, [game]);
// The admin's own rows: the scores in a direct game, the overrides in a judged game.
const adminValues = useMemo(() => {
const values: Record<string, number> = {};
for (const [teamId, cells] of Object.entries(game.cells)) {
for (const [criterionId, cell] of Object.entries(cells)) {
if (cell.adminValue !== null) values[cellKey(teamId, criterionId)] = cell.adminValue;
}
}
return values;
}, [game.cells]);
const save = (teamId: string, criterionId: string, value: number | null) => {
const key = cellKey(teamId, criterionId);
const version = ++versions.current;
setPending((current) => ({ ...current, [key]: { value, version, savedAt: null } }));
setSavingCount((count) => count + 1);
setError(null);
// Only the newest edit of a cell may update or clear its pending state.
const settle = (savedAt: number | null) =>
setPending((current) => {
if (current[key]?.version !== version) return current;
if (savedAt === null) {
return Object.fromEntries(Object.entries(current).filter(([other]) => other !== key));
}
return { ...current, [key]: { ...current[key], savedAt } };
});
saveAdminScore(eventId, game.id, teamId, criterionId, value)
.then((result) => {
if (result.ok) {
settle(Date.now());
router.refresh();
} else {
settle(null);
setError(result.error);
}
})
.catch(() => {
settle(null);
setError("That score wasn't saved — couldn't reach the server. Check your connection and enter it again.");
})
.finally(() => setSavingCount((count) => count - 1));
};
const save = useCallback(
(key: string, value: number | null) => {
const [teamId, criterionId] = key.split("|");
return saveAdminScore(eventId, game.id, teamId, criterionId, value);
},
[eventId, game.id],
);
const refresh = useCallback(() => router.refresh(), [router]);
const { edit, valueOf, isSaving, errorFor } = usePendingEdits(adminValues, save, refresh);
// Judged games show averages; the override input for a cell only opens on request,
// which keeps a 5-criteria grid on screen without sideways scrolling.
const [overriding, setOverriding] = useState<string | null>(null);
if (game.criteria.length === 0) {
return <p>This game has no criteria yet. Add some on the setup page.</p>;
@@ -91,6 +57,7 @@ export function ScoringGrid({
}
const judged = game.scoringMode === "judged";
const error = errorFor();
return (
<div className="flex flex-col gap-2">
@@ -120,8 +87,9 @@ export function ScoringGrid({
<tbody>
{teams.map((team) => {
const result = game.results[team.id];
const spotlit = game.spotlightTeamId === team.id;
return (
<tr key={team.id} className="bg-chalk">
<tr key={team.id} className={spotlit ? "bg-floodlight/25" : "bg-chalk"}>
<th scope="row" className="rounded-l-md py-2 pr-3 pl-2 font-bold whitespace-nowrap">
<span
aria-hidden
@@ -129,32 +97,57 @@ export function ScoringGrid({
style={{ backgroundColor: team.color }}
/>
{team.name}
{spotlit && <span className="block text-sm font-normal">★ Now performing</span>}
</th>
{game.criteria.map((criterion) => {
const cell = game.cells[team.id]?.[criterion.id];
const edit = pending[cellKey(team.id, criterion.id)];
const value = edit ? edit.value : (cell?.adminValue ?? null);
const key = cellKey(team.id, criterion.id);
const value = valueOf(key);
const inputProps = {
range: criterion,
value,
label: `${criterion.label} for ${team.name}`,
onChange: (next: number | null) => save(team.id, criterion.id, next),
label: `${judged ? "Override value: " : ""}${criterion.label} for ${team.name}`,
onChange: (next: number | null) => edit(key, next),
};
return (
<td key={criterion.id} className="px-2 py-2 align-top">
{criterion.inputType === "blocks" ? (
{judged && (
<span className="mb-1 block text-sm">
{cell?.judgeAverage == null ? (
<span className="text-ink/70">No judge scores yet</span>
) : (
<>
<strong
className={`text-lg tabular-nums ${value !== null ? "text-ink/50 line-through" : ""}`}
>
{formatScore(cell.judgeAverage)}
</strong>{" "}
<span className="text-ink/70">
avg of {cell.judgeCount} {cell.judgeCount === 1 ? "judge" : "judges"}
</span>
</>
)}
{value !== null && (
<strong className="ml-1 rounded bg-alert px-1.5 text-white">
Override {formatScore(value)}
</strong>
)}
</span>
)}
{judged && value === null && overriding !== key ? (
<Button
variant="secondary"
size="sm"
aria-label={`Override ${criterion.label} for ${team.name}`}
onClick={() => setOverriding(key)}
>
Override
</Button>
) : criterion.inputType === "blocks" ? (
<BlockInput compact color={team.color} {...inputProps} />
) : (
<NumberStepper {...inputProps} />
)}
{judged && (
<span className="mt-1 block text-sm text-ink/70">
{cell?.judgeAverage == null
? "No judge scores"
: `Judges: ${formatScore(cell.judgeAverage)} (${cell.judgeCount})`}
{value !== null && " · overridden"}
</span>
)}
</td>
);
})}
@@ -174,13 +167,13 @@ export function ScoringGrid({
</table>
</div>
<p role="status" className="text-sm font-bold">
{savingCount > 0 ? "Saving…" : error ? "" : "All scores saved ✓"}
{isSaving() ? "Saving…" : error ? "" : "All scores saved ✓"}
</p>
<ErrorText>{error}</ErrorText>
{judged && (
<p className="text-sm text-ink/70">
This is a judged game. A value you enter here overrides the judges&apos; average for that
cell; clear it to use the judges again.
&ldquo;Override&rdquo; replaces the judges&apos; average for that cell until you clear it
(tap the selected block again, or empty the number).
</p>
)}
</div>
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
/** "Copy link" button plus a QR code that is only generated when opened. */
export function ShareLink({ url, copyLabel, qrLabel }: { url: string; copyLabel: string; qrLabel: string }) {
const [copied, setCopied] = useState<"idle" | "copied" | "failed">("idle");
const [showQr, setShowQr] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied("copied");
} catch {
// Clipboard access can be blocked (e.g. on http over a LAN address).
setCopied("failed");
}
setTimeout(() => setCopied("idle"), 2500);
};
return (
<>
<Button variant="secondary" size="sm" onClick={copy}>
{copied === "copied" ? "Copied ✓" : copyLabel}
</Button>
<Button variant="secondary" size="sm" aria-expanded={showQr} onClick={() => setShowQr(!showQr)}>
{showQr ? "Hide QR" : "QR code"}
</Button>
{(showQr || copied === "failed") && (
<div className="flex basis-full flex-col items-start gap-2">
{copied === "failed" && <p className="text-sm">Couldn&apos;t copy automatically. Select the link below.</p>}
{showQr && <QrCode url={url} label={qrLabel} />}
<code className="max-w-full rounded bg-chalk px-2 py-1 text-sm break-all select-all">{url}</code>
</div>
)}
</>
);
}
function QrCode({ url, label }: { url: string; label: string }) {
const [svg, setSvg] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
// Loaded on demand so the QR library isn't in the page until someone asks for a code.
void import("qrcode")
.then((QRCode) => QRCode.toString(url, { type: "svg", margin: 2, errorCorrectionLevel: "M" }))
.then((markup) => {
if (!cancelled) setSvg(markup);
});
return () => {
cancelled = true;
};
}, [url]);
if (!svg) return <div className="size-48 rounded bg-chalk" aria-busy="true" />;
return (
<div
role="img"
aria-label={label}
className="size-48 rounded border border-ink/15 bg-white"
// Markup comes from the qrcode library, generated locally from our own URL.
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { BlockInput, NumberStepper } from "@/components/ui/score-inputs";
import { usePendingEdits, type SaveResult } from "@/lib/hooks/use-pending-edits";
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
import { judgeMessages, type JudgePayload } from "@/lib/judge";
import { channelNames } from "@/lib/realtime-channels";
const cellKey = (teamId: string, criterionId: string) => `${teamId}|${criterionId}`;
/** The judge's phone page: one card per team, one input per criterion, autosave on every tap. */
export function JudgeView({ token, initial }: { token: string; initial: JudgePayload }) {
const [payload, setPayload] = useState(initial);
// Set when the link stops working (revoked) while the page is open.
const [linkOff, setLinkOff] = useState<string | null>(null);
const [offline, setOffline] = useState(false);
const refetch = useCallback(async () => {
try {
const response = await fetch(`/api/judge/${token}`, { cache: "no-store" });
if (response.status === 403 || response.status === 404) {
setLinkOff(((await response.json()) as { error: string }).error);
return;
}
if (!response.ok) throw new Error(String(response.status));
setPayload((await response.json()) as JudgePayload);
setOffline(false);
} catch {
setOffline(true);
}
}, [token]);
// The game channel pings when the host locks judging, moves the spotlight or edits the game.
useRefreshChannel(channelNames.game(payload.game.id), refetch);
const serverValues = useMemo(
() => Object.fromEntries(payload.scores.map((score) => [cellKey(score.teamId, score.criterionId), score.value])),
[payload.scores],
);
const save = useCallback(
async (key: string, value: number | null): Promise<SaveResult> => {
const [teamId, criterionId] = key.split("|");
const response = await fetch(`/api/judge/${token}/scores`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ teamId, criterionId, value }),
});
if (response.ok) return { ok: true };
const { error } = (await response.json().catch(() => ({}))) as { error?: string };
// Locked, finished or revoked: reload so the page shows why.
if ([403, 404, 409].includes(response.status)) void refetch();
return { ok: false, error: error ?? "That score wasn't saved. Try again." };
},
[token, refetch],
);
const { edit, valueOf, isSaving, errorFor } = usePendingEdits(serverValues, save, refetch);
if (linkOff) return <JudgeMessage>{linkOff}</JudgeMessage>;
const readOnly = payload.state !== "active";
// Spotlighted team first; everyone else keeps the host's order.
const teams = [...payload.teams].sort(
(a, b) => Number(b.id === payload.spotlightTeamId) - Number(a.id === payload.spotlightTeamId),
);
return (
<main className="mx-auto flex min-h-dvh max-w-xl flex-col gap-4 p-4 pb-12">
<header>
<h1 className="font-display text-4xl font-bold">{payload.game.title}</h1>
<p className="text-lg">Judging as {payload.judgeName}</p>
{payload.game.judgeNotes && (
<details className="mt-2 rounded-md border border-ink/15 bg-white">
<summary className="flex min-h-11 cursor-pointer items-center px-3 font-bold">Judge notes</summary>
<p className="px-3 pb-3 whitespace-pre-wrap">{payload.game.judgeNotes}</p>
</details>
)}
</header>
{readOnly && (
<p role="status" className="rounded-md border-2 border-ink bg-white p-3 font-bold">
{judgeMessages[payload.state as Exclude<JudgePayload["state"], "active">]}
</p>
)}
{offline && (
<p role="status" className="rounded-md border-2 border-alert bg-white p-3 font-bold">
You&apos;re offline. Scores you tap now won&apos;t save until the connection is back.
</p>
)}
{payload.teams.length === 0 && <p>No teams yet. The host is still setting up.</p>}
{teams.map((team) => {
const inTeam = (key: string) => key.startsWith(`${team.id}|`);
const spotlit = team.id === payload.spotlightTeamId;
const scored = payload.criteria.filter((criterion) => valueOf(cellKey(team.id, criterion.id)) !== null).length;
const error = errorFor(inTeam);
return (
<section
key={team.id}
aria-label={team.name}
className={`flex flex-col gap-3 rounded-lg border-2 bg-white p-4 ${
spotlit ? "border-ink shadow-lg" : "border-ink/15"
}`}
>
<h2 className="flex items-center gap-2 text-2xl font-bold">
<span aria-hidden className="inline-block size-5 rounded-full" style={{ backgroundColor: team.color }} />
{spotlit && <span className="rounded bg-floodlight px-2 text-base">★ Now</span>}
{team.name}
</h2>
{payload.criteria.map((criterion) => {
const key = cellKey(team.id, criterion.id);
const inputProps = {
range: criterion,
value: valueOf(key),
label: `${criterion.label} for ${team.name}`,
disabled: readOnly,
onChange: (next: number | null) => edit(key, next),
};
return (
<div key={criterion.id} className="flex flex-col gap-1">
<p className="font-bold">
{criterion.label}
{criterion.description && (
<span className="block text-sm font-normal text-ink/70">{criterion.description}</span>
)}
</p>
{criterion.inputType === "blocks" ? (
<BlockInput color={team.color} {...inputProps} />
) : (
<NumberStepper {...inputProps} />
)}
</div>
);
})}
<p role="status" className="min-h-6 text-right text-sm font-bold">
{isSaving(inTeam) ? (
"Saving…"
) : error ? (
<span className="text-alert">{error}</span>
) : scored > 0 ? (
`Saved ✓${scored < payload.criteria.length ? ` · ${scored} of ${payload.criteria.length} scored` : ""}`
) : (
""
)}
</p>
</section>
);
})}
</main>
);
}
export function JudgeMessage({ children }: { children: string }) {
return (
<main className="mx-auto flex min-h-dvh max-w-xl items-center justify-center p-6 text-center">
<p className="text-2xl font-bold">{children}</p>
</main>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Field, inputClass } from "@/components/ui/field";
const storageKey = (openToken: string) => `gnl-judge:${openToken}`;
/** Open link: type a name, become a judge, land on the personal judge page. */
export function OpenLinkForm({ openToken, gameTitle }: { openToken: string; gameTitle: string }) {
const router = useRouter();
const [name, setName] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
// Until we've looked in localStorage we don't know whether to show the form.
const [checked, setChecked] = useState(false);
// Someone who already joined on this phone goes straight back to their own page,
// so a refresh or re-scan doesn't create a second judge.
useEffect(() => {
let saved: string | null = null;
try {
saved = localStorage.getItem(storageKey(openToken));
} catch {
// Storage can be blocked (private mode); they'll just see the form.
}
if (saved && /^[0-9a-f]{32}$/.test(saved)) router.replace(`/j/${saved}`);
// eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage is only readable after mount
else setChecked(true);
}, [openToken, router]);
const join = async () => {
setPending(true);
setError(null);
try {
const response = await fetch(`/api/judge-open/${openToken}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const body = (await response.json()) as { judgeToken?: string; error?: string };
if (!response.ok || !body.judgeToken) {
setError(body.error ?? "Couldn't join. Try again.");
setPending(false);
return;
}
try {
localStorage.setItem(storageKey(openToken), body.judgeToken);
} catch {
// Without storage a refresh of this page would ask for the name again; the
// personal page they are about to land on still works on its own.
}
router.replace(`/j/${body.judgeToken}`);
} catch {
setError("Couldn't reach the server. Check your connection and try again.");
setPending(false);
}
};
if (!checked) return null;
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center gap-5 p-6">
<div>
<h1 className="font-display text-4xl font-bold">{gameTitle}</h1>
<p className="mt-1 text-lg">You&apos;re joining as a judge. What&apos;s your name?</p>
</div>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void join();
}}
>
<Field label="Your name">
<input
value={name}
onChange={(event) => setName(event.target.value)}
required
maxLength={60}
autoComplete="name"
autoFocus
className={inputClass}
/>
</Field>
<Button type="submit" disabled={pending}>
{pending ? "Joining…" : "Start judging"}
</Button>
<p role="alert" className="min-h-6 font-bold text-alert">
{error}
</p>
</form>
</main>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
// An edit the server data hasn't caught up with yet. Shown instead of the server's value
// so a tap feels instant (optimistic UI). Used by the control room grid and the judge page.
type PendingEdit = { value: number | null; version: number; savedAt: number | null };
export type SaveResult = { ok: true } | { ok: false; error: string };
// A saved edit normally disappears when refreshed data matches it. If it never matches
// (someone else changed the cell), stop masking the server's value after this long.
const STALE_MS = 5000;
/**
* Autosave for a set of numeric cells addressed by string keys.
* @param serverValues the latest saved value per key (absent = not scored)
* @param save writes one cell; its promise settling drives the saving/error state
* @param onSaved called after each successful save, e.g. to refetch
*/
export function usePendingEdits(
serverValues: Record<string, number>,
save: (key: string, value: number | null) => Promise<SaveResult>,
onSaved?: () => void,
) {
const [pending, setPending] = useState<Record<string, PendingEdit>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const versions = useRef(0);
// Drop optimistic edits once the server data has caught up with them.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reconciling local edits with fresh server data
setPending((current) => {
const now = Date.now();
const kept = Object.entries(current).filter(
([key, edit]) =>
edit.savedAt === null ||
((serverValues[key] ?? null) !== edit.value && now - edit.savedAt < STALE_MS),
);
return kept.length === Object.keys(current).length ? current : Object.fromEntries(kept);
});
}, [serverValues]);
const edit = useCallback(
(key: string, value: number | null) => {
const version = ++versions.current;
setPending((current) => ({ ...current, [key]: { value, version, savedAt: null } }));
setErrors((current) => without(current, key));
// Only the newest edit of a cell may update or clear its pending state.
const settle = (savedAt: number | null) =>
setPending((current) => {
if (current[key]?.version !== version) return current;
return savedAt === null ? without(current, key) : { ...current, [key]: { ...current[key], savedAt } };
});
const failed = (message: string) => {
settle(null);
setErrors((current) => ({ ...current, [key]: message }));
};
save(key, value)
.then((result) => {
if (result.ok) {
settle(Date.now());
onSaved?.();
} else {
failed(result.error);
}
})
.catch(() =>
failed("That score wasn't saved — couldn't reach the server. Check your connection and enter it again."),
);
},
[save, onSaved],
);
/** The value to display: the unsaved edit if there is one, else the server's. */
const valueOf = (key: string): number | null =>
key in pending ? pending[key].value : (serverValues[key] ?? null);
const isSaving = (matches: (key: string) => boolean = () => true) =>
Object.entries(pending).some(([key, { savedAt }]) => savedAt === null && matches(key));
const errorFor = (matches: (key: string) => boolean = () => true) =>
Object.entries(errors).find(([key]) => matches(key))?.[1] ?? null;
return { edit, valueOf, isSaving, errorFor };
}
function without<T>(record: Record<string, T>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([other]) => other !== key));
}
+186
View File
@@ -0,0 +1,186 @@
import "server-only";
import {
checkJudgeWrite,
judgeMessages,
judgeState,
type JudgePayload,
type JudgeScoreInput,
type WriteCheck,
} from "@/lib/judge";
import { notify } from "@/lib/realtime";
import { createAdminClient } from "@/lib/supabase/admin";
import { judgeTokenSchema, openTokenSchema } from "@/lib/tokens";
// Everything here uses the service-role client, so the token check at the top of each
// function is the only gate (CLAUDE.md rule 2). Every query is scoped by what that token grants.
const MAX_JUDGES_PER_GAME = 50;
export type JudgeLookup =
| { ok: true; payload: JudgePayload }
| { ok: false; status: 403 | 404; error: string };
export async function getJudgePayload(token: string): Promise<JudgeLookup> {
const parsed = judgeTokenSchema.safeParse(token);
if (!parsed.success) return { ok: false, status: 404, error: judgeMessages.linkOff };
const supabase = createAdminClient();
const { data: judge, error } = await supabase
.from("judges")
.select("id, name, revoked, games!inner(*)")
.eq("token", parsed.data)
.maybeSingle();
if (error) throw new Error(`Could not load the judge: ${error.message}`);
if (!judge) return { ok: false, status: 404, error: judgeMessages.linkOff };
if (judge.revoked) return { ok: false, status: 403, error: judgeMessages.linkOff };
const game = judge.games;
const [teams, criteria, scores] = await Promise.all([
supabase
.from("teams")
.select("id, name, color")
.eq("event_id", game.event_id)
.order("sort_order")
.order("created_at"),
supabase.from("criteria").select("*").eq("game_id", game.id).order("sort_order"),
// Only this judge's rows.
supabase.from("scores").select("team_id, criterion_id, value").eq("judge_id", judge.id),
]);
const loadError = teams.error ?? criteria.error ?? scores.error;
if (loadError) throw new Error(`Could not load the game: ${loadError.message}`);
return {
ok: true,
payload: {
state: judgeState(game),
judgeName: judge.name,
// Only what a judge should see: no admin notes, tokens or other judges.
game: { id: game.id, title: game.title, judgeNotes: game.judge_notes },
spotlightTeamId: game.spotlight_team_id,
criteria: (criteria.data ?? []).map((criterion) => ({
id: criterion.id,
label: criterion.label,
description: criterion.description,
inputType: criterion.input_type,
min: criterion.min_value,
max: criterion.max_value,
step: criterion.step,
})),
teams: teams.data ?? [],
scores: (scores.data ?? []).map((score) => ({
teamId: score.team_id,
criterionId: score.criterion_id,
value: score.value,
})),
},
};
}
export async function saveJudgeScore(token: string, input: JudgeScoreInput): Promise<WriteCheck> {
const parsed = judgeTokenSchema.safeParse(token);
if (!parsed.success) return { ok: false, status: 404, error: judgeMessages.linkOff };
const supabase = createAdminClient();
// One round trip for all three lookups; checkJudgeWrite ties them together.
const [judge, criterion, team] = await Promise.all([
supabase
.from("judges")
.select("id, revoked, game_id, games!inner(id, event_id, scoring_mode, status, judging_locked)")
.eq("token", parsed.data)
.maybeSingle(),
supabase
.from("criteria")
.select("game_id, min_value, max_value, step")
.eq("id", input.criterionId)
.maybeSingle(),
supabase.from("teams").select("event_id").eq("id", input.teamId).maybeSingle(),
]);
const loadError = judge.error ?? criterion.error ?? team.error;
if (loadError) throw new Error(`Could not check the score: ${loadError.message}`);
const check = checkJudgeWrite({
judge: judge.data,
game: judge.data?.games ?? null,
criterion: criterion.data,
team: team.data,
value: input.value,
});
if (!check.ok || !judge.data) return check;
const { id: judgeId, game_id: gameId, games: game } = judge.data;
const { error } =
input.value === null
? await supabase
.from("scores")
.delete()
.eq("judge_id", judgeId)
.eq("team_id", input.teamId)
.eq("criterion_id", input.criterionId)
: await supabase.from("scores").upsert(
{
game_id: gameId,
team_id: input.teamId,
criterion_id: input.criterionId,
judge_id: judgeId,
value: input.value,
},
{ onConflict: "team_id,criterion_id,judge_id" },
);
if (error) throw new Error(`Could not save the score: ${error.message}`);
// No gameId: other judges don't see this score, so only the host and scoreboard are pinged.
await notify({ eventId: game.event_id });
return { ok: true };
}
export type OpenLinkLookup =
| { ok: true; gameId: string; eventId: string; gameTitle: string }
| { ok: false; status: 404; error: string };
/** An open link works only while the host has it switched on for a judged game. */
export async function getOpenLinkGame(openToken: string): Promise<OpenLinkLookup> {
const off = { ok: false, status: 404, error: judgeMessages.linkOff } as const;
const parsed = openTokenSchema.safeParse(openToken);
if (!parsed.success) return off;
const { data: game, error } = await createAdminClient()
.from("games")
.select("id, event_id, title, open_link_enabled, scoring_mode")
.eq("open_token", parsed.data)
.maybeSingle();
if (error) throw new Error(`Could not load the game: ${error.message}`);
if (!game || !game.open_link_enabled || game.scoring_mode !== "judged") return off;
return { ok: true, gameId: game.id, eventId: game.event_id, gameTitle: game.title };
}
export type JoinResult =
| { ok: true; judgeToken: string }
| { ok: false; status: 404 | 409; error: string };
export async function joinViaOpenLink(openToken: string, name: string): Promise<JoinResult> {
const game = await getOpenLinkGame(openToken);
if (!game.ok) return game;
const supabase = createAdminClient();
// Anyone with the link can join, so cap it: a shared link can't flood the game with judges.
const { count, error: countError } = await supabase
.from("judges")
.select("id", { count: "exact", head: true })
.eq("game_id", game.gameId);
if (countError) throw new Error(`Could not join: ${countError.message}`);
if ((count ?? 0) >= MAX_JUDGES_PER_GAME) {
return { ok: false, status: 409, error: "This game already has the maximum number of judges. Ask the host." };
}
const { data: judge, error } = await supabase
.from("judges")
.insert({ game_id: game.gameId, name, source: "open_link" })
.select("token")
.single();
if (error) throw new Error(`Could not join: ${error.message}`);
await notify({ eventId: game.eventId });
return { ok: true, judgeToken: judge.token };
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { checkJudgeWrite, judgeNameSchema, judgeScoreSchema, judgeState } from "./judge";
const game = { id: "g1", event_id: "e1", scoring_mode: "judged", status: "live", judging_locked: false };
const facts = {
judge: { revoked: false, game_id: "g1" },
game,
criterion: { game_id: "g1", min_value: 1, max_value: 5, step: 0.5 },
team: { event_id: "e1" },
value: 3.5 as number | null,
};
const status = (overrides: Partial<Parameters<typeof checkJudgeWrite>[0]>) => {
const result = checkJudgeWrite({ ...facts, ...overrides });
return result.ok ? 200 : result.status;
};
describe("checkJudgeWrite", () => {
it("accepts a valid score, the range ends, and a clear", () => {
expect(status({})).toBe(200);
expect(status({ value: 1 })).toBe(200);
expect(status({ value: 5 })).toBe(200);
expect(status({ value: null })).toBe(200);
});
it("refuses an unknown or revoked judge", () => {
expect(status({ judge: null })).toBe(404);
expect(status({ game: null })).toBe(404);
expect(status({ judge: { revoked: true, game_id: "g1" } })).toBe(403);
});
it("refuses when the game is locked, done or not judged — even for a clear", () => {
expect(status({ game: { ...game, judging_locked: true } })).toBe(409);
expect(status({ game: { ...game, status: "done" }, value: null })).toBe(409);
expect(status({ game: { ...game, scoring_mode: "direct" } })).toBe(409);
});
it("refuses a criterion from another game or a team from another event", () => {
expect(status({ criterion: { ...facts.criterion, game_id: "g2" } })).toBe(400);
expect(status({ criterion: null })).toBe(400);
expect(status({ team: { event_id: "e2" } })).toBe(400);
expect(status({ team: null })).toBe(400);
});
it("refuses values out of range or off step", () => {
expect(status({ value: 0.5 })).toBe(400);
expect(status({ value: 5.5 })).toBe(400);
expect(status({ value: 3.25 })).toBe(400);
});
it("tolerates float noise on fractional steps", () => {
const criterion = { game_id: "g1", min_value: 0, max_value: 1, step: 0.1 };
expect(status({ criterion, value: 0.3 })).toBe(200);
expect(status({ criterion, value: 0.7 })).toBe(200);
});
});
describe("judgeState", () => {
it("reports why a judge is read-only, most fundamental reason first", () => {
expect(judgeState(game)).toBe("active");
expect(judgeState({ ...game, status: "upcoming" })).toBe("active");
expect(judgeState({ ...game, judging_locked: true })).toBe("locked");
expect(judgeState({ ...game, judging_locked: true, status: "done" })).toBe("done");
expect(judgeState({ ...game, scoring_mode: "direct", status: "done" })).toBe("direct");
});
});
describe("request schemas", () => {
const id = "3f2b8c1e-9d4a-4e6f-8b7a-1c2d3e4f5a6b";
it("validates a score body", () => {
expect(judgeScoreSchema.safeParse({ teamId: id, criterionId: id, value: 4 }).success).toBe(true);
expect(judgeScoreSchema.safeParse({ teamId: id, criterionId: id, value: null }).success).toBe(true);
expect(judgeScoreSchema.safeParse({ teamId: "x", criterionId: id, value: 4 }).success).toBe(false);
expect(judgeScoreSchema.safeParse({ teamId: id, criterionId: id, value: "4" }).success).toBe(false);
expect(judgeScoreSchema.safeParse({ teamId: id, criterionId: id }).success).toBe(false);
});
it("trims and bounds a judge name", () => {
expect(judgeNameSchema.parse({ name: " Melissa " }).name).toBe("Melissa");
expect(judgeNameSchema.safeParse({ name: " " }).success).toBe(false);
expect(judgeNameSchema.safeParse({ name: "x".repeat(61) }).success).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { z } from "zod";
import { idSchema } from "@/lib/tokens";
// Types and rules shared by the judge API and the judge page. Pure: no DB access.
export const judgeMessages = {
linkOff: "This link has been turned off. Ask the host for a new one.",
locked: "The host has locked judging for this game. Your scores are saved.",
done: "This game is finished. Your scores are saved.",
direct: "The host is scoring this game directly, so your scores aren't needed.",
} as const;
/** Why a judge can or can't score right now. */
export type JudgeState = "active" | "locked" | "done" | "direct";
export type JudgePayload = {
state: JudgeState;
judgeName: string;
game: { id: string; title: string; judgeNotes: string | null };
spotlightTeamId: string | null;
criteria: Array<{
id: string;
label: string;
description: string | null;
inputType: "blocks" | "number";
min: number;
max: number;
step: number;
}>;
teams: Array<{ id: string; name: string; color: string }>;
/** This judge's own scores only — never other judges' or any totals (PRD 4.4). */
scores: Array<{ teamId: string; criterionId: string; value: number }>;
};
export const judgeScoreSchema = z.object({
teamId: idSchema,
criterionId: idSchema,
/** null clears the score. */
value: z.number().min(-999999).max(999999).nullable(),
});
export type JudgeScoreInput = z.infer<typeof judgeScoreSchema>;
export const judgeNameSchema = z.object({
name: z
.string()
.trim()
.min(1, "Enter your name.")
.max(60, "Your name can be at most 60 characters."),
});
type GameFacts = { id: string; event_id: string; scoring_mode: string; status: string; judging_locked: boolean };
export function judgeState(game: Pick<GameFacts, "scoring_mode" | "status" | "judging_locked">): JudgeState {
if (game.scoring_mode !== "judged") return "direct";
if (game.status === "done") return "done";
if (game.judging_locked) return "locked";
return "active";
}
export type WriteCheck = { ok: true } | { ok: false; status: number; error: string };
const refuse = (status: number, error: string): WriteCheck => ({ ok: false, status, error });
/**
* Every rule a judge's score must pass (docs/ARCHITECTURE.md, "Judge write checks").
* Rows are looked up by the ids the phone sent, so each one must be tied back to the
* judge's own game here — otherwise a judge could write into another game or event.
*/
export function checkJudgeWrite(facts: {
judge: { revoked: boolean; game_id: string } | null;
game: GameFacts | null;
criterion: { game_id: string; min_value: number; max_value: number; step: number } | null;
team: { event_id: string } | null;
value: number | null;
}): WriteCheck {
const { judge, game, criterion, team, value } = facts;
if (!judge || !game) return refuse(404, judgeMessages.linkOff);
if (judge.revoked) return refuse(403, judgeMessages.linkOff);
const state = judgeState(game);
if (state !== "active") return refuse(409, judgeMessages[state]);
if (!criterion || criterion.game_id !== judge.game_id) {
return refuse(400, "That criterion isn't part of this game. Reload the page.");
}
if (!team || team.event_id !== game.event_id) {
return refuse(400, "That team isn't part of this game night. Reload the page.");
}
if (value !== null) {
if (value < criterion.min_value || value > criterion.max_value) {
return refuse(400, `That score must be between ${criterion.min_value} and ${criterion.max_value}.`);
}
const steps = (value - criterion.min_value) / criterion.step;
if (Math.abs(steps - Math.round(steps)) > 1e-9) {
return refuse(400, `That score must go up in steps of ${criterion.step}.`);
}
}
return { ok: true };
}
+26
View File
@@ -23,6 +23,14 @@ export type LiveCriterion = {
step: number;
};
export type LiveJudge = {
id: string;
name: string;
revoked: boolean;
/** "teamId|criterionId" → this judge's value */
values: Record<string, number>;
};
export type LiveCell = CellResult & {
/** The admin's own row: the score in a direct game, the override in a judged game. */
adminValue: number | null;
@@ -36,6 +44,10 @@ export type LiveGame = {
pointsMode: PointsMode;
multiplier: number;
adminNotes: string | null;
judgingLocked: boolean;
spotlightTeamId: string | null;
/** Judged games only; never includes judge tokens. */
judges: LiveJudge[];
criteria: LiveCriterion[];
/** teamId → criterionId → cell */
cells: Record<string, Record<string, LiveCell>>;
@@ -100,6 +112,20 @@ export function buildLiveView(data: EventData): LiveView {
pointsMode: game.points_mode,
multiplier: game.multiplier,
adminNotes: game.admin_notes,
judgingLocked: game.judging_locked,
spotlightTeamId: game.spotlight_team_id,
judges: data.judges
.filter((judge) => judge.game_id === game.id)
.map((judge) => ({
id: judge.id,
name: judge.name,
revoked: judge.revoked,
values: Object.fromEntries(
data.scores
.filter((score) => score.judge_id === judge.id)
.map((score) => [`${score.team_id}|${score.criterion_id}`, score.value]),
),
})),
criteria: data.criteria
.filter((criterion) => criterion.game_id === game.id)
.map((criterion) => ({
+7 -3
View File
@@ -4,7 +4,10 @@ import type { Database, Tables } from "@/lib/database.types";
type Client = SupabaseClient<Database>;
export type GameWithCriteria = Tables<"games"> & { criteria: Tables<"criteria">[] };
export type GameWithCriteria = Tables<"games"> & {
criteria: Tables<"criteria">[];
judges: Tables<"judges">[];
};
export type EventSetup = {
event: Tables<"events">;
@@ -24,11 +27,12 @@ export async function loadEventSetup(supabase: Client, eventId: string): Promise
.order("created_at"),
supabase
.from("games")
.select("*, criteria(*)")
.select("*, criteria(*), judges(*)")
.eq("event_id", eventId)
.order("sort_order")
.order("created_at")
.order("sort_order", { referencedTable: "criteria" }),
.order("sort_order", { referencedTable: "criteria" })
.order("created_at", { referencedTable: "judges" }),
]);
const error = event.error ?? teams.error ?? games.error;
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { createRateLimiter } from "./rate-limit";
describe("createRateLimiter", () => {
it("allows a burst up to the limit, then refuses", () => {
const allow = createRateLimiter(3);
expect([allow("a", 0), allow("a", 0), allow("a", 0), allow("a", 0)]).toEqual([true, true, true, false]);
});
it("refills over time", () => {
const allow = createRateLimiter(2);
allow("a", 0);
allow("a", 0);
expect(allow("a", 100)).toBe(false);
expect(allow("a", 600)).toBe(true);
expect(allow("a", 600)).toBe(false);
});
it("never banks more than one second's worth", () => {
const allow = createRateLimiter(2);
allow("a", 0);
expect([allow("a", 60_000), allow("a", 60_000), allow("a", 60_000)]).toEqual([true, true, false]);
});
it("tracks keys separately and forgets the least recently used", () => {
const allow = createRateLimiter(1, 2);
expect(allow("a", 0)).toBe(true);
expect(allow("b", 0)).toBe(true);
expect(allow("a", 0)).toBe(false);
// "c" evicts "b" (least recently used), so "b" starts fresh.
expect(allow("c", 0)).toBe(true);
expect(allow("b", 0)).toBe(true);
});
});
+23
View File
@@ -0,0 +1,23 @@
type Bucket = { tokens: number; updatedAt: number };
/**
* In-memory token bucket: `perSecond` requests per key, with bursts up to the same amount.
* Per server instance, which is enough for v1: it stops a stuck phone or a script from
* hammering one link, not a determined attacker (docs/ARCHITECTURE.md).
*/
export function createRateLimiter(perSecond: number, maxKeys = 5000) {
const buckets = new Map<string, Bucket>();
return function allow(key: string, now = Date.now()): boolean {
const bucket = buckets.get(key) ?? { tokens: perSecond, updatedAt: now };
const refilled = Math.min(perSecond, bucket.tokens + ((now - bucket.updatedAt) / 1000) * perSecond);
const allowed = refilled >= 1;
// Re-insert so the Map's order is least-recently-used first, then evict the oldest.
buckets.delete(key);
buckets.set(key, { tokens: allowed ? refilled - 1 : refilled, updatedAt: now });
if (buckets.size > maxKeys) buckets.delete(buckets.keys().next().value!);
return allowed;
};
}