Phase 10: team buzzers

Each team gets a buzzer link (/b/[token]); a game opts in with a
"Teams buzz in" checkbox. The database's clock orders the buzzes, so
the result doesn't depend on phone clocks. The control room lists the
order and clears it per question (B); the scoreboard shows who buzzed
first with a ding.

Buzzes follow the existing rules: token-gated route handlers with the
service-role client, RLS on the new table, and data-free realtime pings
on a new buzzer:{eventId} channel.

Needs supabase/migrations/0003_buzzer.sql applied before deploying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:33:44 +02:00
co-authored by Claude Opus 5.5
parent 48cecfff7f
commit bbcf84bbc3
38 changed files with 969 additions and 31 deletions
+25 -4
View File
@@ -17,6 +17,9 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
| `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}` |
| `/b/[buzzerToken]` | team | Team buzzer page |
| `GET /api/buzzer/[token]` | team | Team, current game, buzz order |
| `POST /api/buzzer/[token]/buzz` | team | Buzz in (no body) → the same payload |
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()`.
@@ -30,15 +33,17 @@ 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 (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.
- Buzz checks: the token's team exists, its event's current game has `buzzer_enabled` and isn't done. The team and game come from the token alone — the request has no body. One row per (game, team), so a repeat press is a no-op.
- Light rate limiting on judge PUT (20 req/s per token), buzz POST (5 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 (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)
- `admin:{eventId}` — admin control room listens (judge submissions and buzzes land here)
- `buzzer:{eventId}` — team buzzer pages listen (pinged for buzzes, clears, game switches and edits to games/teams — not for scores)
`src/lib/realtime.ts` exposes `notify({ eventId, gameId? })` which looks up the scoreboard token and sends to the right channels from the server.
`src/lib/realtime.ts` exposes `notify({ eventId, gameId?, buzzer? })` which looks up the scoreboard token and sends to the right channels from the server. `buzzer: true` adds the buzzer channel; score saves leave it out so they don't cost an extra request.
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.
@@ -66,6 +71,11 @@ type ScoreboardPayload = {
endsAt: string | null; // set while running
durationMs: number;
} | null;
// null unless the current game uses buzzers; sent regardless of score visibility
buzzer: {
open: boolean; // false once the game is done
buzzes: Array<{ teamId: string; name: string; color: string; afterFirstMs: number }>; // in buzz order
} | null;
updatedAt: string; // server clock; screens use it to correct their own
};
```
@@ -80,11 +90,22 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live
change triggers is decided by pure functions in `src/lib/scoreboard-sounds.ts`; the
scoreboard compares each new payload with the previous one. No sound data travels over Realtime.
## Buzzers
- A buzz is a row in `buzzes`, stamped by the database's clock (`clock_timestamp()`), so the order
doesn't depend on phone clocks or on which server instance took the request. Network delay
between a thumb and the database still counts — fair on shared Wi-Fi, not tournament-grade.
- `src/lib/buzzer.ts` holds the rules as pure functions (`buzzerView` builds the same
`{ open, buzzes }` for the team page, the control room and the scoreboard);
`src/lib/buzzer.server.ts` does the token-gated reads and the write.
- Buzzes follow rule 4 like everything else: the ping carries no data and each screen refetches.
The buzzing phone gets its result in the POST response, so it doesn't wait for the ping.
- Clearing = deleting the current game's rows (admin server action).
## Folder layout
```
src/
app/ (routes above)
components/ (ui/, scoreboard/, judge/, admin/)
components/ (ui/, scoreboard/, judge/, buzzer/, admin/)
lib/
scoring/ (index.ts, *.test.ts)
supabase/ (browser.ts, server.ts, admin.ts)
+8
View File
@@ -65,5 +65,13 @@ human a short "how to test this manually" checklist. Don't start the next phase
- Scoreboard sound effects: reveal drum-roll + crash/fanfare, game-change chime, last-10-seconds ticks + buzzer; "Turn sound on" button.
**Done when:** a timer started in the control room counts down on the scoreboard with ticks and a buzzer, and a reveal plays its drum-roll.
## Phase 10 — Team buzzers
- Apply `supabase/migrations/0003_buzzer.sql`, then `npm run gen:types` and `npm run check:rls`.
- Buzzer rules in `src/lib/buzzer.ts` (tested); `/b/[token]` phone page + `GET`/`POST` buzzer API.
- "Teams buzz in" checkbox per game; buzzer links per team in the share panel.
- Control room: buzz order + "Clear buzzers" (`B`). Scoreboard: "{Team} buzzed!" banner + ding.
**Done when:** two phones on different teams buzz in a buzzer game; the control room and scoreboard
show who was first within a second, and "Clear buzzers" resets both phones.
## Stretch (only if asked)
Participants within teams · co-host admins · CSV export of results.
+5 -3
View File
@@ -1,20 +1,22 @@
# Data model
Full SQL in `supabase/migrations/0001_init.sql`.
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`).
| Table | Purpose | Key columns |
|---|---|---|
| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count`, `timer_visible`, `timer_duration_ms`, `timer_remaining_ms`, `timer_ends_at` |
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order` |
| `games` | Activities | `scoring_mode`, `points_mode`, `multiplier`, `placement_points int[]`, `status`, `admin_notes`, `judge_notes`, `open_token`, `open_link_enabled`, `judging_locked`, `spotlight_team_id` |
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order`, `buzzer_token` |
| `games` | Activities | `scoring_mode`, `points_mode`, `multiplier`, `placement_points int[]`, `status`, `admin_notes`, `judge_notes`, `open_token`, `open_link_enabled`, `judging_locked`, `spotlight_team_id`, `buzzer_enabled` |
| `criteria` | Scoring blocks per game | `label`, `description`, `input_type`, `min_value`, `max_value`, `step`, `weight` |
| `judges` | Judges per game | `name`, `token`, `revoked`, `source` (nominated/open_link) |
| `scores` | One value per (team, criterion, judge) | `judge_id` NULL = admin score (direct mode or override) |
| `adjustments` | Bonus/penalty | `points` (+/-), `reason`, optional `game_id` |
| `buzzes` | Who buzzed for the current question | `game_id`, `team_id`, `buzzed_at` |
## Notes
- `scores` unique on `(team_id, criterion_id, judge_id)` with `NULLS NOT DISTINCT`, so there's exactly one admin row per team+criterion → clean upserts.
- In **judged** games, an admin row (`judge_id IS NULL`) is an **override** for that cell.
- Deleting a criterion/team/judge cascades its scores.
- Timer (`0002_timer.sql`): running = `timer_ends_at` set; paused = `timer_remaining_ms` set; reset = both null (full `timer_duration_ms`).
- Buzzers (`0003_buzzer.sql`): `buzzes` is unique on `(game_id, team_id)` — one buzz per team per question; `buzzed_at` defaults to `clock_timestamp()` and decides the order. Clearing the buzzers deletes the game's rows. A trigger checks the team is in the game's event. `teams.buzzer_token` is a 128-bit token like the others; duplicating an event gives the copied teams new ones.
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
+28 -3
View File
@@ -10,6 +10,7 @@ live scoreboard, and judges score from their phones via links. Versatile enough
| **Admin** | Everything | Logs in (magic link). Email must be in `ADMIN_EMAILS`. |
| **Scoreboard viewer** | Read-only scoreboard for ONE event | Public link `/s/{scoreboardToken}` |
| **Judge** | Score ONE game | Personal link `/j/{judgeToken}` OR open game link `/jo/{openToken}` (enters name first) |
| **Team** | Buzz in for ONE team | Team buzzer link `/b/{buzzerToken}`, one per team for the whole night |
## 3. Core concepts
- **Event** — one game night. Has teams, games, one scoreboard link, visibility state.
@@ -38,11 +39,13 @@ live scoreboard, and judges score from their phones via links. Versatile enough
- Criteria list: add/remove/reorder; label, description, input type, min/max/step, weight.
- Quick presets: "Karaoke (5 × 1–5)", "Single score (0–10)", "Points (number)", "Win/Lose (0–1)".
- Admin notes + judge notes (multiline).
- "Teams buzz in" checkbox: this game uses the team buzzers (see 4.7).
- Judges (judged games only):
- Nominate judges by name → each gets a personal link (copy button + QR code).
- Toggle the open link on/off; anyone opening it types their name and becomes a judge.
- Revoke a judge (link stops working, their scores excluded).
- Share panel: scoreboard link + QR, all judge links per game.
- Share panel: scoreboard link + QR, all judge links per game, and one buzzer link + QR per team
(shown once any game uses buzzers).
### 4.2 Admin — Control room (`/admin/events/[id]/live`)
This is the screen used during the night. Must be fast and hard to mess up.
@@ -62,7 +65,9 @@ This is the screen used during the night. Must be fast and hard to mess up.
Recent adjustments list with undo.
- Scoreboard visibility: **Visible / Frozen / Hidden** (see 4.3). Big obvious toggle.
- "Open scoreboard" button (new tab) + "Reveal mode" button (see 4.3).
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer, `?` help.
- Buzzers (buzzer games only): who buzzed, in order, with each team's gap behind the first; "Clear buzzers".
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer,
`B` clear buzzers, `?` help.
### 4.3 Scoreboard (`/s/[token]`)
- Projector-first, 16:9, readable from the back of a hall.
@@ -106,8 +111,26 @@ Played by the scoreboard page, so they come out of the projector PC's speakers.
- Timer: a tick for each of the last 10 seconds (sharper for the last 3) and a buzzer at zero.
- Browsers only allow sound after a click on the page, so the scoreboard has a "Turn sound on"
button. The choice is remembered, but after a reload one click anywhere is needed again.
- Buzzer: a two-note ding when a team is first to buzz.
- Sounds are synthesised in the browser (no audio files). With reduced motion the drum-roll is skipped.
### 4.7 Buzzers (`/b/[token]`)
For "first to answer" games. A game opts in with the "Teams buzz in" checkbox; scoring is unchanged
(the host or the judges still enter the points).
- Every team has one buzzer link for the whole night. Someone on the team opens it on a phone:
team name, the current game, and one big "Buzz" button.
- The button works only while the current game uses buzzers and isn't done. Otherwise the page
says there's no buzzer for this game, and wakes up by itself when one starts.
- The server decides the order, using the database's clock — never the phones' clocks.
- A team buzzes once per question; pressing again does nothing. Later teams can still buzz, so
the host knows who is next if the first answer is wrong.
- The phone shows "You buzzed first!" (the screen turns the team's colour) or its place and who was first.
- The control room lists the order with the gap behind the first buzz ("+0.42s"). "Clear buzzers"
(or `B`) empties it for the next question.
- The scoreboard shows "{Team} buzzed!" in the team's colour, with the following teams after it,
and plays a ding. It is not a score, so it shows whether scores are visible, frozen or hidden.
- The phone asks the browser to keep the screen on while the page is open.
## 5. Non-functional
- Live latency target < 1s on normal Wi-Fi.
- Works with ~10 teams, ~20 games, ~10 judges per game without lag.
@@ -117,4 +140,6 @@ Played by the scoreboard page, so they come out of the projector PC's speakers.
## 6. Out of scope (v1)
- Individual contestants within teams (stretch: `participants` per team).
- Multiple admins / co-hosts (stretch).
- Per-game timers and buzzers for contestants (stretch). The event countdown timer is in 4.5.
- Per-game timers (stretch). The event countdown timer is in 4.5; team buzzers are in 4.7.
- Buzzers for individual contestants, automatic points for buzzing first, and locking a team out
after a wrong answer (the host just reads the next team from the order).
+22
View File
@@ -75,6 +75,28 @@ Collapses to stacked panels on tablet.
```
Selected block filled in team colour. Tap the selected block again to clear.
### Buzzer (phone)
```
┌─────────────────────────┐
│ ● Lions │
│ Bible quiz │
│ │
│ ╭─────────╮ │
│ │ │ │
│ │ Buzz │ │
│ │ │ │
│ ╰─────────╯ │
│ │
│ Ready. Tap to buzz in. │
└─────────────────────────┘
```
One round button in the team's colour, as big as the screen allows; it reacts on touch-down, not
on release. After buzzing it shows the team's place ("1st", "2nd"). First in: the whole screen
turns the team's colour. Greyed out while there is no buzzer game.
On the scoreboard a buzz is a banner in the team's colour above the standings: "Lions buzzed!",
then the later teams in small text. It pops in once; reduced motion shows it without the pop.
## Copy
Plain verbs: "Next game", "Hide scores", "Freeze scores", "Show scores", "Add bonus", "Copy judge link".
Errors say what happened + what to do: "This link has been turned off. Ask the host for a new one."