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:
@@ -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.
|
||||
|
||||
Generated
+275
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' average for that
|
||||
cell; clear it to use the judges again.
|
||||
“Override” replaces the judges' average for that cell until you clear it
|
||||
(tap the selected block again, or empty the number).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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'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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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're offline. Scores you tap now won'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>
|
||||
);
|
||||
}
|
||||
@@ -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're joining as a judge. What'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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user