- buildScoreboardPayload is the single place deciding what the public may see: visible, frozen (snapshot scores, live current game), hidden (no scores, alphabetical) and reveal (unrevealed teams carry no score/order). - GET /api/public/scoreboard/[token] validates the token, then loads the event with the service client in one parallel round trip. - notify() pings sb/admin/game channels over Realtime's REST endpoint with no data; useRefreshChannel refetches immediately on a ping, throttles bursts, and falls back to a 10s poll, focus and reconnect. - /s/[token]: FLIP rank animation, count-up totals, reduced motion, fullscreen, cursor hide and a connection indicator. - Admin setup actions now notify, so renames and reorders show up live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.1 KiB
Architecture
Stack
Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast) · Tailwind · zod · Vitest.
Routes
| Route | Who | Notes |
|---|---|---|
/login |
admin | Supabase magic link |
/admin |
admin | Events list |
/admin/events/[eventId] |
admin | Setup: teams, games, criteria, judges, share links |
/admin/events/[eventId]/live |
admin | Control room |
/s/[scoreboardToken] |
public | Scoreboard |
/j/[judgeToken] |
judge | Personal judge page |
/jo/[openToken] |
judge | Open link → name form |
GET /api/public/scoreboard/[token] |
public | Sanitised scoreboard payload |
GET /api/judge/[token] |
judge | Game, criteria, teams, this judge's scores |
PUT /api/judge/[token]/scores |
judge | Upsert one score {teamId, criterionId, value} |
POST /api/judge-open/[openToken] |
judge | {name} → {judgeToken} |
Admin mutations = server actions. src/proxy.ts (Next.js 16's name for middleware) protects /admin/*: must be logged in AND email in ADMIN_EMAILS. Every server action re-checks via requireAdmin().
Supabase clients (src/lib/supabase/)
browser.ts— anon key, used for Realtime subscribe + admin auth session.server.ts— cookie-based, for admin server actions (RLS applies as the admin user).admin.ts— service role, server-only (import 'server-only'). Used by public/judge route handlers after token validation.
Security model
- RLS ON for every table. Policies only for the authenticated event owner. Anon role has no policies → no direct table access.
- 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_visibilityand strips scores whenhidden; returnsfrozen_snapshotwhenfrozen. - Light rate limiting on judge PUT (e.g. 20 req/s per token, in-memory is fine for v1).
Realtime (Broadcast "ping then refetch")
Channels (names use tokens/ids; payload is always just { type: "refresh", scope }):
sb:{scoreboardToken}— scoreboard listensgame:{gameId}— all judges of that game listenadmin:{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.
Use Supabase's server-side broadcast (REST endpoint / httpSend — check current docs).
Throttle refetches client-side: the first ping refetches immediately, further pings within ~150ms collapse into one trailing refetch, so a burst of taps causes at most two fetches.
Clients also poll every 10s as a safety net, and refetch on reconnect / tab focus.
Scoring engine (src/lib/scoring/)
Pure TS, no DB access. Input = plain data (teams, games, criteria, scores, adjustments). Output = per-game results + standings. See SCORING.md. Fully unit tested.
Scoreboard payload shape
type ScoreboardPayload = {
eventName: string;
visibility: 'visible' | 'frozen' | 'hidden';
currentGame: { id: string; title: string; index: number; total: number } | null;
reveal: { active: boolean; revealedCount: number } | null;
teams: Array<{
id: string; name: string; color: string;
// omitted entirely when hidden:
rank?: number; total?: number;
// v1 sends totals only; no per-game points.
}>;
updatedAt: string;
};
During reveal mode, teams not yet revealed are sent without rank/total, alphabetically and before the revealed ones, so their order leaks nothing.
When frozen, rank/total come from frozen_snapshot but currentGame stays live. A missing or invalid snapshot is treated as hidden, never as live.
Folder layout
src/
app/ (routes above)
components/ (ui/, scoreboard/, judge/, admin/)
lib/
scoring/ (index.ts, *.test.ts)
supabase/ (browser.ts, server.ts, admin.ts)
realtime.ts
tokens.ts (zod schemas)
queries/ (data loaders shared by admin + API)
supabase/migrations/