diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd034e0..ceb2496 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 907b8c3..6cbede7 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 7bdc190..63ca008 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -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. diff --git a/docs/PRD.md b/docs/PRD.md index be687c0..4eb3fcc 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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). diff --git a/docs/UI.md b/docs/UI.md index 1917d7a..58dc5eb 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -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." diff --git a/scripts/check-rls.mjs b/scripts/check-rls.mjs index e393e6b..964d2d4 100644 --- a/scripts/check-rls.mjs +++ b/scripts/check-rls.mjs @@ -2,7 +2,7 @@ // Run with: npm run check:rls import { createClient } from "@supabase/supabase-js"; -const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments"]; +const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments", "buzzes"]; const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index 288713f..5e2fe94 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -77,7 +77,7 @@ export async function updateEvent( revalidatePath("/admin"); revalidatePath(eventPath(eventId)); - await notify({ eventId }); + await notify({ eventId, buzzer: true }); return ok; } @@ -178,7 +178,7 @@ export async function updateTeam( if (error) return dbFail("Saving the team", error.message); revalidatePath(eventPath(eventId)); - await notify({ eventId }); + await notify({ eventId, buzzer: true }); return ok; } @@ -190,7 +190,7 @@ export async function deleteTeam(eventId: string, teamId: string): Promise

Share links

- + ); diff --git a/src/app/admin/live-actions.ts b/src/app/admin/live-actions.ts index de82be4..3842484 100644 --- a/src/app/admin/live-actions.ts +++ b/src/app/admin/live-actions.ts @@ -54,7 +54,7 @@ export async function setCurrentGame(eventId: string, gameId: string): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, gameId)) return BAD_ID; + + // The game must be in this event; RLS alone would allow a game from another of your events. + const game = await supabase.from("games").select("id").eq("id", gameId).eq("event_id", eventId).maybeSingle(); + if (game.error) return dbFail("Clearing the buzzers", game.error.message); + if (!game.data) return BAD_ID; + + const { error } = await supabase.from("buzzes").delete().eq("game_id", gameId); + if (error) return dbFail("Clearing the buzzers", error.message); + + revalidatePath(livePath(eventId)); + await notify({ eventId, buzzer: true }); return ok; } diff --git a/src/app/api/buzzer/[token]/buzz/route.ts b/src/app/api/buzzer/[token]/buzz/route.ts new file mode 100644 index 0000000..a0d2975 --- /dev/null +++ b/src/app/api/buzzer/[token]/buzz/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; + +import { buzz } from "@/lib/buzzer.server"; +import { createRateLimiter } from "@/lib/rate-limit"; + +const headers = { "Cache-Control": "no-store" }; +// A team buzzes once per question; this only stops a stuck phone or a script. +const allow = createRateLimiter(5); + +/** Buzzes in for this team. No body: the token says who, the server says when. */ +export async function POST(_request: Request, context: RouteContext<"/api/buzzer/[token]/buzz">) { + 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 }, + ); + } + + try { + const result = await buzz(token); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers }); + return NextResponse.json(result.payload, { headers }); + } catch (error) { + console.error("buzz failed", error); + return NextResponse.json({ error: "That buzz didn't go through. Try again." }, { status: 500, headers }); + } +} diff --git a/src/app/api/buzzer/[token]/route.ts b/src/app/api/buzzer/[token]/route.ts new file mode 100644 index 0000000..fbb00f9 --- /dev/null +++ b/src/app/api/buzzer/[token]/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; + +import { getBuzzerPayload } from "@/lib/buzzer.server"; + +const headers = { "Cache-Control": "no-store" }; + +export async function GET(_request: Request, context: RouteContext<"/api/buzzer/[token]">) { + const { token } = await context.params; + try { + const result = await getBuzzerPayload(token); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers }); + return NextResponse.json(result.payload, { headers }); + } catch (error) { + console.error("buzzer API failed", error); + return NextResponse.json({ error: "Could not load the buzzer. Try again." }, { status: 500, headers }); + } +} diff --git a/src/app/b/[token]/page.tsx b/src/app/b/[token]/page.tsx new file mode 100644 index 0000000..a091db0 --- /dev/null +++ b/src/app/b/[token]/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; + +import { BuzzerMessage, BuzzerView } from "@/components/buzzer/buzzer-view"; +import { getBuzzerPayload } from "@/lib/buzzer.server"; + +export const metadata: Metadata = { + title: "Buzzer", + // The link is the team's only credential; keep it out of search engines. + robots: { index: false, follow: false }, +}; + +export default async function BuzzerPage(props: PageProps<"/b/[token]">) { + const { token } = await props.params; + const result = await getBuzzerPayload(token); + + if (!result.ok) return {result.error}; + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index b9a895f..d92b0c8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -119,3 +119,25 @@ body { animation: none; } } + +/* Scoreboard buzzer: the banner pops in when a team is first to buzz. */ +@keyframes buzz-pop { + from { + transform: scale(0.6); + opacity: 0; + } + 60% { + transform: scale(1.06); + opacity: 1; + } +} + +.buzz-pop { + animation: buzz-pop 350ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .buzz-pop { + animation: none; + } +} diff --git a/src/components/admin/buzzer-panel.tsx b/src/components/admin/buzzer-panel.tsx new file mode 100644 index 0000000..0c96c3c --- /dev/null +++ b/src/components/admin/buzzer-panel.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { clearBuzzes } from "@/app/admin/live-actions"; +import { Button } from "@/components/ui/button"; +import { formatBuzzGap, type BuzzerView } from "@/lib/buzzer"; + +import { ErrorText } from "./error-text"; +import { useAction } from "./use-action"; + +/** Who buzzed for the current question, in order, and the button that clears them for the next. */ +export function BuzzerPanel({ eventId, gameId, buzzer }: { eventId: string; gameId: string; buzzer: BuzzerView }) { + const { pending, error, run } = useAction(); + const { buzzes } = buzzer; + + return ( +
+
+

Buzzers

+ +
+ + {buzzes.length === 0 ? ( +

+ {buzzer.open ? "Waiting for a buzz…" : "Closed — the game is done."} +

+ ) : ( +
    + {buzzes.map((entry, index) => ( +
  1. + {index + 1} + + {entry.name} + {index === 0 ? "first" : formatBuzzGap(entry.afterFirstMs)} +
  2. + ))} +
+ )} + {error} +
+ ); +} diff --git a/src/components/admin/control-room.tsx b/src/components/admin/control-room.tsx index fb789d6..ab514b5 100644 --- a/src/components/admin/control-room.tsx +++ b/src/components/admin/control-room.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { + clearBuzzes, pauseTimer, setCurrentGame, setGameStatus, @@ -18,6 +19,7 @@ import type { LiveView } from "@/lib/live-view"; import { channelNames } from "@/lib/realtime-channels"; import { BonusPanel } from "./bonus-panel"; +import { BuzzerPanel } from "./buzzer-panel"; import { ErrorText } from "./error-text"; import { JudgedPanel } from "./judged-panel"; import { ScoreboardPanel, VISIBILITY_ORDER } from "./scoreboard-panel"; @@ -33,6 +35,7 @@ const shortcuts = [ ["P", "Previous game"], ["H", "Cycle what the room sees: visible → frozen → hidden"], ["T", "Start or pause the timer"], + ["B", "Clear the buzzers for the next question"], ["?", "Show or hide this help"], ] as const; @@ -71,6 +74,11 @@ export function ControlRoom({ view }: { view: LiveView }) { run(() => (timer.status === "running" ? pauseTimer(event.id) : startTimer(event.id))); }, [event.id, pending, run, timer.status]); + const buzzerGameId = currentGame?.buzzer?.buzzes.length ? currentGame.id : undefined; + const clearBuzzers = useCallback(() => { + if (buzzerGameId && !pending) run(() => clearBuzzes(event.id, buzzerGameId)); + }, [buzzerGameId, event.id, pending, run]); + useEffect(() => { const onKeyDown = (keyEvent: KeyboardEvent) => { // Never steal keys from a field the host is typing in. @@ -83,12 +91,13 @@ export function ControlRoom({ view }: { view: LiveView }) { else if (key === "p") goTo(previousGame?.id); else if (key === "h") cycleVisibility(); else if (key === "t") toggleTimer(); + else if (key === "b") clearBuzzers(); else if (key === "?") setShowHelp((shown) => !shown); else if (key === "escape") setShowHelp(false); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [cycleVisibility, goTo, nextGame?.id, previousGame?.id, toggleTimer]); + }, [clearBuzzers, cycleVisibility, goTo, nextGame?.id, previousGame?.id, toggleTimer]); const teamsById = new Map(teams.map((team) => [team.id, team])); @@ -211,6 +220,9 @@ export function ControlRoom({ view }: { view: LiveView }) { {currentGame ? ( <> + {currentGame.buzzer && ( + + )} {/* Keyed by game so half-saved edits never carry over to another game. */} {currentGame.scoringMode === "judged" && ( diff --git a/src/components/admin/game-form.tsx b/src/components/admin/game-form.tsx index 8b9f872..aca3990 100644 --- a/src/components/admin/game-form.tsx +++ b/src/components/admin/game-form.tsx @@ -41,6 +41,7 @@ function toDraft(game: GameWithCriteria): Draft { pointsMode: game.points_mode, multiplier: game.multiplier, placementPoints: game.placement_points.join(", "), + buzzerEnabled: game.buzzer_enabled, adminNotes: game.admin_notes ?? "", judgeNotes: game.judge_notes ?? "", criteria: game.criteria.map((criterion) => ({ @@ -176,6 +177,21 @@ export function GameForm({ eventId, game }: { eventId: string; game: GameWithCri /> )} +
diff --git a/src/components/admin/share-panel.test.ts b/src/components/admin/share-panel.test.ts index 90375d8..8bc597e 100644 --- a/src/components/admin/share-panel.test.ts +++ b/src/components/admin/share-panel.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@/lib/env.public", () => ({ publicEnv: { NEXT_PUBLIC_SITE_URL: "https://gamenight.example" } })); -const { judgeLinksText } = await import("./share-panel"); +const { buzzerLinksText, judgeLinksText } = await import("./share-panel"); const judge = (name: string, token: string, revoked = false) => ({ id: token, game_id: "g", name, token, revoked, source: "nominated", created_at: "" }) as const; @@ -18,3 +18,15 @@ describe("judgeLinksText", () => { ); }); }); + +describe("buzzerLinksText", () => { + it("lists one line per team", () => { + const text = buzzerLinksText([ + { name: "Lions", buzzer_token: "aaa" }, + { name: "Eagles", buzzer_token: "bbb" }, + ]); + expect(text).toBe( + "Buzzer links\nLions: https://gamenight.example/b/aaa\nEagles: https://gamenight.example/b/bbb", + ); + }); +}); diff --git a/src/components/admin/share-panel.tsx b/src/components/admin/share-panel.tsx index a37ed11..22b241d 100644 --- a/src/components/admin/share-panel.tsx +++ b/src/components/admin/share-panel.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; +import type { Tables } from "@/lib/database.types"; import { publicEnv } from "@/lib/env.public"; import type { GameWithCriteria } from "@/lib/queries/event"; @@ -18,9 +19,25 @@ export function judgeLinksText(game: Pick) return [`${game.title} — judge links`, ...lines].join("\n"); } -/** Every link for the night in one place: the scoreboard, and each judged game's judges. */ -export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string; games: GameWithCriteria[] }) { +type BuzzerTeam = Pick, "id" | "name" | "buzzer_token">; + +/** Plain text for pasting into WhatsApp: one line per team. */ +export function buzzerLinksText(teams: Array>): string { + return ["Buzzer links", ...teams.map((team) => `${team.name}: ${site}/b/${team.buzzer_token}`)].join("\n"); +} + +/** Every link for the night in one place: the scoreboard, the team buzzers, and each judged game's judges. */ +export function SharePanel({ + scoreboardToken, + teams, + games, +}: { + scoreboardToken: string; + teams: BuzzerTeam[]; + games: GameWithCriteria[]; +}) { const judgedGames = games.filter((game) => game.scoring_mode === "judged"); + const usesBuzzers = games.some((game) => game.buzzer_enabled); const localOnly = /\/\/(localhost|127\.0\.0\.1)/.test(site); return ( @@ -41,6 +58,32 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string /> + {/* One link per team for the whole night; it only does something during a buzzer game. */} + {usesBuzzers && ( +
+
+

Team buzzers

+ {teams.length > 0 && } +
+ {teams.length === 0 ? ( +

No teams yet. Add teams above and each gets a buzzer link.

+ ) : ( +
    + {teams.map((team) => ( +
  • + {team.name} + +
  • + ))} +
+ )} +
+ )} + {judgedGames.length === 0 ? (

No judged games yet. Set a game to “Judged” to give judges their own links.

) : ( @@ -50,7 +93,9 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string

{game.title}

- {activeJudges.length > 0 && } + {activeJudges.length > 0 && ( + + )}
{activeJudges.length === 0 && !game.open_link_enabled && (

No judges yet. Open this game below to add judges or turn on its open link.

@@ -85,7 +130,7 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string ); } -function CopyAllButton({ text }: { text: string }) { +function CopyAllButton({ text, label }: { text: string; label: string }) { const [state, setState] = useState<"idle" | "copied" | "failed">("idle"); return ( @@ -103,7 +148,7 @@ function CopyAllButton({ text }: { text: string }) { } }} > - {state === "copied" ? "Copied ✓" : "Copy all judge links"} + {state === "copied" ? "Copied ✓" : label} {state === "failed" && (
diff --git a/src/components/buzzer/buzzer-view.tsx b/src/components/buzzer/buzzer-view.tsx new file mode 100644 index 0000000..0c154ab --- /dev/null +++ b/src/components/buzzer/buzzer-view.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { buzzerMessages, ordinal, type BuzzerPayload } from "@/lib/buzzer"; +import { readableTextColor } from "@/lib/color"; +import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel"; +import { channelNames } from "@/lib/realtime-channels"; + +/** A team's phone page: one big button. The server decides who was first. */ +export function BuzzerView({ token, initial }: { token: string; initial: BuzzerPayload }) { + const [payload, setPayload] = useState(initial); + // Set when the link stops working (the team was deleted) while the page is open. + const [linkOff, setLinkOff] = useState(null); + const [offline, setOffline] = useState(false); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + // A press fires on pointer-down and again as a click; only the first may send. + const busy = useRef(false); + + const refetch = useCallback(async () => { + try { + const response = await fetch(`/api/buzzer/${token}`, { cache: "no-store" }); + if (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 BuzzerPayload); + setOffline(false); + } catch { + setOffline(true); + } + }, [token]); + + // Pinged when anyone buzzes, the host clears the buzzers, or the game changes. + useRefreshChannel(channelNames.buzzer(payload.eventId), refetch); + useScreenAwake(); + + const { team, currentGame, buzzer } = payload; + const position = buzzer ? buzzer.buzzes.findIndex((entry) => entry.teamId === team.id) : -1; + const first = buzzer?.buzzes[0]; + const canBuzz = Boolean(buzzer?.open) && position < 0 && !sending; + + const press = async () => { + if (!canBuzz || busy.current) return; + busy.current = true; + setSending(true); + setError(null); + // A small kick so the player knows the tap landed, where the phone supports it. + navigator.vibrate?.(60); + try { + const response = await fetch(`/api/buzzer/${token}/buzz`, { method: "POST" }); + const body = (await response.json().catch(() => ({}))) as BuzzerPayload & { error?: string }; + if (response.ok) { + setPayload(body); + setOffline(false); + } else if (response.status === 404) { + setLinkOff(body.error ?? buzzerMessages.linkOff); + } else { + setError(body.error ?? "That buzz didn't go through. Try again."); + // The game changed or finished under us: show why. + if (response.status === 409) void refetch(); + } + } catch { + setError("That buzz didn't go through. Check your connection and try again."); + } finally { + busy.current = false; + setSending(false); + } + }; + + if (linkOff) return {linkOff}; + + const status = !buzzer + ? buzzerMessages.noGame + : position === 0 + ? "You buzzed first!" + : position > 0 + ? `You buzzed ${ordinal(position + 1)}. ${first?.name} was first.` + : sending + ? "Buzzing…" + : !buzzer.open + ? buzzerMessages.closed + : first + ? `${first.name} buzzed first. You can still buzz in.` + : "Ready. Tap to buzz in."; + + const won = position === 0; + const textColor = readableTextColor(team.color); + + return ( +
+
+

+ {!won && ( + + )} + {team.name} +

+

{currentGame ? currentGame.title : payload.eventName}

+
+ + {offline && ( +

+ You're offline. The buzzer won't work until the connection is back. +

+ )} + +
+ +
+ +

+ {error ? {error} : status} +

+
+ ); +} + +export function BuzzerMessage({ children }: { children: string }) { + return ( +
+

{children}

+
+ ); +} + +/** Keeps the phone's screen on while the buzzer is open: a sleeping phone can't buzz. */ +function useScreenAwake() { + useEffect(() => { + let lock: WakeLockSentinel | null = null; + let stopped = false; + const request = async () => { + try { + const next = await navigator.wakeLock?.request("screen"); + if (stopped) void next?.release(); + else lock = next ?? null; + } catch { + // Not supported, or refused (e.g. battery saver): the screen just dims as usual. + } + }; + // The browser drops the lock whenever the tab is hidden, so take it again on return. + const onVisible = () => { + if (document.visibilityState === "visible") void request(); + }; + void request(); + document.addEventListener("visibilitychange", onVisible); + return () => { + stopped = true; + document.removeEventListener("visibilitychange", onVisible); + void lock?.release(); + }; + }, []); +} diff --git a/src/components/scoreboard/scoreboard.tsx b/src/components/scoreboard/scoreboard.tsx index afa49f1..6d994db 100644 --- a/src/components/scoreboard/scoreboard.tsx +++ b/src/components/scoreboard/scoreboard.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { BuzzEntry } from "@/lib/buzzer"; import { readableTextColor } from "@/lib/color"; import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel"; import { useSound } from "@/lib/hooks/use-sound"; @@ -45,7 +46,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo () => new Set(initial.teams.filter((team) => team.rank !== undefined).map((team) => team.id)), ); - // Sound effects for what just changed: a new game, a team being revealed. + // Sound effects for what just changed: a new game, a team being revealed, a first buzz. const sound = useSound(); const { play } = sound; const previousPayload = useRef(initial); @@ -123,6 +124,11 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo {payload.timer && } + {payload.buzzer && payload.buzzer.buzzes.length > 0 && ( + // Keyed by the first team so the pop replays when a new question has a new winner. + + )} + {hidden && (

Scores are hidden — final reveal soon @@ -244,6 +250,26 @@ function TeamRow({ ); } +/** Who buzzed: the first team fills the banner in its colour, the rest follow in order. */ +function BuzzBanner({ buzzes }: { buzzes: BuzzEntry[] }) { + const [first, ...rest] = buzzes; + + return ( +

+ {first.name} buzzed! + {rest.length > 0 && ( + + then {rest.map((entry) => entry.name).join(", ")} + + )} +
+ ); +} + /** True after `ms` without mouse or keyboard activity (hides the cursor on the projector). */ function useIdle(ms: number) { const [idle, setIdle] = useState(false); diff --git a/src/lib/buzzer.server.ts b/src/lib/buzzer.server.ts new file mode 100644 index 0000000..0fe686a --- /dev/null +++ b/src/lib/buzzer.server.ts @@ -0,0 +1,93 @@ +import "server-only"; + +import { buzzerMessages, buzzerOpen, buzzerView, type BuzzerPayload } from "@/lib/buzzer"; +import { loadBuzzes } from "@/lib/queries/scoring"; +import { notify } from "@/lib/realtime"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { buzzerTokenSchema } 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. + +type Supabase = ReturnType; + +const linkOff = { ok: false, status: 404, error: buzzerMessages.linkOff } as const; + +/** The team behind a buzzer link, with its event's current game (if any). */ +async function loadTeam(supabase: Supabase, token: string) { + const parsed = buzzerTokenSchema.safeParse(token); + if (!parsed.success) return null; + + const { data: team, error } = await supabase + .from("teams") + .select("id, name, color, events!inner(id, name, current_game_id)") + .eq("buzzer_token", parsed.data) + .maybeSingle(); + if (error) throw new Error(`Could not load the team: ${error.message}`); + if (!team) return null; + + const event = team.events; + if (!event.current_game_id) return { team, event, game: null }; + + const { data: game, error: gameError } = await supabase + .from("games") + .select("id, title, status, buzzer_enabled") + .eq("id", event.current_game_id) + .maybeSingle(); + if (gameError) throw new Error(`Could not load the game: ${gameError.message}`); + return { team, event, game }; +} + +export type BuzzerLookup = + | { ok: true; payload: BuzzerPayload } + | { ok: false; status: 404 | 409; error: string }; + +export async function getBuzzerPayload(token: string): Promise { + const supabase = createAdminClient(); + const found = await loadTeam(supabase, token); + if (!found) return linkOff; + const { team, event, game } = found; + + const [teams, buzzes] = await Promise.all([ + supabase.from("teams").select("id, name, color").eq("event_id", event.id), + game?.buzzer_enabled ? loadBuzzes(supabase, game.id) : { data: [], error: null }, + ]); + const loadError = teams.error ?? buzzes.error; + if (loadError) throw new Error(`Could not load the buzzers: ${loadError.message}`); + + return { + ok: true, + payload: { + eventId: event.id, + eventName: event.name, + // Only what a team should see: no tokens, no scores. + team: { id: team.id, name: team.name, color: team.color }, + currentGame: game ? { id: game.id, title: game.title } : null, + buzzer: buzzerView(game, teams.data ?? [], buzzes.data ?? []), + }, + }; +} + +/** + * Records this team's buzz for the current game and returns what its phone should now show. + * A team buzzes once per question; pressing again changes nothing, so a double tap or a + * retry after a dropped connection can't move it down the order. + */ +export async function buzz(token: string): Promise { + const supabase = createAdminClient(); + const found = await loadTeam(supabase, token); + if (!found) return linkOff; + const { team, event, game } = found; + + if (!game?.buzzer_enabled) return { ok: false, status: 409, error: buzzerMessages.noGame }; + if (!buzzerOpen(game)) return { ok: false, status: 409, error: buzzerMessages.closed }; + + // The database stamps the time and keeps the first row per team (see 0003_buzzer.sql). + const { error } = await supabase + .from("buzzes") + .upsert({ game_id: game.id, team_id: team.id }, { onConflict: "game_id,team_id", ignoreDuplicates: true }); + if (error) throw new Error(`Could not save the buzz: ${error.message}`); + + const [payload] = await Promise.all([getBuzzerPayload(token), notify({ eventId: event.id, buzzer: true })]); + return payload; +} diff --git a/src/lib/buzzer.test.ts b/src/lib/buzzer.test.ts new file mode 100644 index 0000000..e622fbb --- /dev/null +++ b/src/lib/buzzer.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { buzzerOpen, buzzerView, formatBuzzGap, ordinal } from "./buzzer"; + +const teams = [ + { id: "lions", name: "Lions", color: "#E63946" }, + { id: "eagles", name: "Eagles", color: "#3A86FF" }, +]; +const live = { buzzer_enabled: true, status: "live" }; +const buzz = (team_id: string, buzzed_at: string) => ({ team_id, buzzed_at }); + +describe("buzzerOpen", () => { + it("is open only for a buzzer game that isn't done", () => { + expect(buzzerOpen(live)).toBe(true); + expect(buzzerOpen({ buzzer_enabled: true, status: "done" })).toBe(false); + expect(buzzerOpen({ buzzer_enabled: false, status: "live" })).toBe(false); + expect(buzzerOpen(null)).toBe(false); + expect(buzzerOpen(undefined)).toBe(false); + }); +}); + +describe("buzzerView", () => { + it("is null when there is no game or it doesn't use buzzers", () => { + expect(buzzerView(null, teams, [])).toBeNull(); + expect(buzzerView({ buzzer_enabled: false, status: "live" }, teams, [])).toBeNull(); + }); + + it("is open and empty before anyone buzzes", () => { + expect(buzzerView(live, teams, [])).toEqual({ open: true, buzzes: [] }); + }); + + it("keeps the given order and measures each team against the first buzz", () => { + const view = buzzerView(live, teams, [ + buzz("eagles", "2026-10-05T19:00:00.100000+00:00"), + buzz("lions", "2026-10-05T19:00:00.520000+00:00"), + ]); + expect(view?.buzzes).toEqual([ + { teamId: "eagles", name: "Eagles", color: "#3A86FF", afterFirstMs: 0 }, + { teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 420 }, + ]); + }); + + it("still shows who buzzed once the game is done, but closed", () => { + const view = buzzerView({ buzzer_enabled: true, status: "done" }, teams, [ + buzz("lions", "2026-10-05T19:00:00.000Z"), + ]); + expect(view?.open).toBe(false); + expect(view?.buzzes).toHaveLength(1); + }); + + it("skips a buzz from a team that has since been deleted", () => { + const view = buzzerView(live, teams, [ + buzz("gone", "2026-10-05T19:00:00.000Z"), + buzz("lions", "2026-10-05T19:00:01.000Z"), + ]); + expect(view?.buzzes.map((entry) => entry.teamId)).toEqual(["lions"]); + }); +}); + +describe("formatting", () => { + it("shows the gap behind the first buzz in hundredths of a second", () => { + expect(formatBuzzGap(420)).toBe("+0.42s"); + expect(formatBuzzGap(1500)).toBe("+1.50s"); + }); + + it("writes positions as ordinals", () => { + expect([1, 2, 3, 4, 11, 12, 13, 21, 22, 103].map(ordinal)).toEqual([ + "1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "103rd", + ]); + }); +}); diff --git a/src/lib/buzzer.ts b/src/lib/buzzer.ts new file mode 100644 index 0000000..d40ae5b --- /dev/null +++ b/src/lib/buzzer.ts @@ -0,0 +1,83 @@ +// Types and rules for the team buzzers, shared by the buzzer API, the scoreboard and the +// control room. Pure: no DB access. + +export const buzzerMessages = { + linkOff: "This link has been turned off. Ask the host for a new one.", + noGame: "No buzzer for this game. Keep this page open.", + closed: "This game is finished, so the buzzers are closed.", +} as const; + +export type BuzzEntry = { + teamId: string; + name: string; + color: string; + /** How long after the first buzz this team buzzed; 0 for the first. */ + afterFirstMs: number; +}; + +export type BuzzerView = { + /** Teams can buzz right now. False once the game is marked done. */ + open: boolean; + /** In the order the teams buzzed. Empty until someone does, and again after a clear. */ + buzzes: BuzzEntry[]; +}; + +/** What a team's phone gets. Nothing here is a score, so it ignores scoreboard visibility. */ +export type BuzzerPayload = { + /** For the realtime channel name only. */ + eventId: string; + eventName: string; + team: { id: string; name: string; color: string }; + currentGame: { id: string; title: string } | null; + /** Null when there is no current game or it doesn't use buzzers. */ + buzzer: BuzzerView | null; +}; + +type GameFacts = { buzzer_enabled: boolean; status: string }; + +/** Buzzers work only for the current game, only if it uses them, and only until it's done. */ +export function buzzerOpen(game: GameFacts | null | undefined): boolean { + return Boolean(game?.buzzer_enabled) && game?.status !== "done"; +} + +/** + * The buzzer state for the current game, or null when it doesn't use buzzers. + * `buzzes` must already be in buzz order (the database's clock decides, see 0003_buzzer.sql). + */ +export function buzzerView( + game: GameFacts | null | undefined, + teams: Array<{ id: string; name: string; color: string }>, + buzzes: Array<{ team_id: string; buzzed_at: string }>, +): BuzzerView | null { + if (!game?.buzzer_enabled) return null; + + const teamsById = new Map(teams.map((team) => [team.id, team])); + const firstAt = buzzes.length ? Date.parse(buzzes[0].buzzed_at) : 0; + return { + open: buzzerOpen(game), + buzzes: buzzes.flatMap((buzz) => { + const team = teamsById.get(buzz.team_id); + if (!team) return []; + return [ + { + teamId: team.id, + name: team.name, + color: team.color, + afterFirstMs: Math.max(0, Date.parse(buzz.buzzed_at) - firstAt), + }, + ]; + }), + }; +} + +/** "+0.42s" behind the first buzz, for the host's list. */ +export function formatBuzzGap(afterFirstMs: number): string { + return `+${(afterFirstMs / 1000).toFixed(2)}s`; +} + +/** 1 → "1st", 2 → "2nd", 11 → "11th". */ +export function ordinal(position: number): string { + const lastTwo = position % 100; + if (lastTwo >= 11 && lastTwo <= 13) return `${position}th`; + return `${position}${["th", "st", "nd", "rd"][position % 10] ?? "th"}`; +} diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index 47b8a17..0ca1485 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -58,6 +58,42 @@ export type Database = { }, ]; }; + buzzes: { + Row: { + buzzed_at: string; + game_id: string; + id: string; + team_id: string; + }; + Insert: { + buzzed_at?: string; + game_id: string; + id?: string; + team_id: string; + }; + Update: { + buzzed_at?: string; + game_id?: string; + id?: string; + team_id?: string; + }; + Relationships: [ + { + foreignKeyName: "buzzes_game_id_fkey"; + columns: ["game_id"]; + isOneToOne: false; + referencedRelation: "games"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "buzzes_team_id_fkey"; + columns: ["team_id"]; + isOneToOne: false; + referencedRelation: "teams"; + referencedColumns: ["id"]; + }, + ]; + }; criteria: { Row: { description: string | null; @@ -173,6 +209,7 @@ export type Database = { games: { Row: { admin_notes: string | null; + buzzer_enabled: boolean; created_at: string; description: string | null; event_id: string; @@ -193,6 +230,7 @@ export type Database = { }; Insert: { admin_notes?: string | null; + buzzer_enabled?: boolean; created_at?: string; description?: string | null; event_id: string; @@ -213,6 +251,7 @@ export type Database = { }; Update: { admin_notes?: string | null; + buzzer_enabled?: boolean; created_at?: string; description?: string | null; event_id?: string; @@ -347,6 +386,7 @@ export type Database = { }; teams: { Row: { + buzzer_token: string; color: string; created_at: string; event_id: string; @@ -355,6 +395,7 @@ export type Database = { sort_order: number; }; Insert: { + buzzer_token?: string; color?: string; created_at?: string; event_id: string; @@ -363,6 +404,7 @@ export type Database = { sort_order?: number; }; Update: { + buzzer_token?: string; color?: string; created_at?: string; event_id?: string; diff --git a/src/lib/live-view.ts b/src/lib/live-view.ts index 3336901..281a815 100644 --- a/src/lib/live-view.ts +++ b/src/lib/live-view.ts @@ -1,3 +1,4 @@ +import { buzzerView, type BuzzerView } from "@/lib/buzzer"; import type { EventData } from "@/lib/queries/scoring"; import { toScoringInput } from "@/lib/queries/scoring"; import { @@ -47,6 +48,8 @@ export type LiveGame = { adminNotes: string | null; judgingLocked: boolean; spotlightTeamId: string | null; + /** Null when this game doesn't use buzzers. */ + buzzer: BuzzerView | null; /** Judged games only; never includes judge tokens. */ judges: LiveJudge[]; criteria: LiveCriterion[]; @@ -118,6 +121,7 @@ export function buildLiveView(data: EventData, now = new Date()): LiveView { adminNotes: game.admin_notes, judgingLocked: game.judging_locked, spotlightTeamId: game.spotlight_team_id, + buzzer: buzzerView(game, data.teams, data.buzzes), judges: data.judges .filter((judge) => judge.game_id === game.id) .map((judge) => ({ diff --git a/src/lib/queries/scoring.ts b/src/lib/queries/scoring.ts index 1da6a1a..7bff2ad 100644 --- a/src/lib/queries/scoring.ts +++ b/src/lib/queries/scoring.ts @@ -13,8 +13,15 @@ export type EventData = { judges: Tables<"judges">[]; scores: Tables<"scores">[]; adjustments: Tables<"adjustments">[]; + /** The current game's buzzes only, in buzz order. Not scoring data. */ + buzzes: Tables<"buzzes">[]; }; +/** One game's buzzes, first buzz first. The id breaks an exact tie the same way every time. */ +export function loadBuzzes(supabase: Client, gameId: string) { + return supabase.from("buzzes").select("*").eq("game_id", gameId).order("buzzed_at").order("id"); +} + const PAGE_SIZE = 1000; // `games!inner()` joins through games purely to filter by event, so child rows load in @@ -44,7 +51,7 @@ async function loadAllScores(supabase: Client, eventId: string): Promise): Promise { - const [teams, games, adjustments, criteria, judges, scores] = await Promise.all([ + const [teams, games, adjustments, criteria, judges, buzzes, scores] = await Promise.all([ supabase.from("teams").select("*").eq("event_id", event.id).order("sort_order").order("created_at"), supabase.from("games").select("*").eq("event_id", event.id).order("sort_order").order("created_at"), supabase.from("adjustments").select("*").eq("event_id", event.id).order("created_at"), @@ -60,9 +67,11 @@ export async function loadEventData(supabase: Client, event: Tables<"events">): .eq("games.event_id", event.id) .order("created_at") .overrideTypes[], { merge: false }>(), + event.current_game_id ? loadBuzzes(supabase, event.current_game_id) : { data: [], error: null }, loadAllScores(supabase, event.id), ]); - const error = teams.error ?? games.error ?? adjustments.error ?? criteria.error ?? judges.error; + const error = + teams.error ?? games.error ?? adjustments.error ?? criteria.error ?? judges.error ?? buzzes.error; if (error) throw new Error(`Could not load the event: ${error.message}`); return { @@ -72,6 +81,7 @@ export async function loadEventData(supabase: Client, event: Tables<"events">): adjustments: adjustments.data ?? [], criteria: criteria.data ?? [], judges: judges.data ?? [], + buzzes: buzzes.data ?? [], scores, }; } diff --git a/src/lib/realtime-channels.ts b/src/lib/realtime-channels.ts index 68529c3..272317e 100644 --- a/src/lib/realtime-channels.ts +++ b/src/lib/realtime-channels.ts @@ -5,4 +5,5 @@ export const channelNames = { scoreboard: (scoreboardToken: string) => `sb:${scoreboardToken}`, game: (gameId: string) => `game:${gameId}`, admin: (eventId: string) => `admin:${eventId}`, + buzzer: (eventId: string) => `buzzer:${eventId}`, }; diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts index f22d541..6da87dd 100644 --- a/src/lib/realtime.ts +++ b/src/lib/realtime.ts @@ -4,7 +4,12 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { channelNames, REFRESH_EVENT } from "./realtime-channels"; -type NotifyArgs = { eventId: string; gameId?: string }; +type NotifyArgs = { + eventId: string; + gameId?: string; + /** Also ping the teams' buzzer pages: for buzzes, and for changes to what those pages show. */ + buzzer?: boolean; +}; // A scoreboard token never changes, so remember it and save a DB round trip per ping. const scoreboardTokens = new Map(); @@ -14,7 +19,7 @@ const scoreboardTokens = new Map(); * The message carries no data (CLAUDE.md rule 4): hidden/frozen rules live only in the API. * Never throws: the mutation already succeeded, and clients poll every 10s as a fallback. */ -export async function notify({ eventId, gameId }: NotifyArgs): Promise { +export async function notify({ eventId, gameId, buzzer }: NotifyArgs): Promise { try { const supabase = createAdminClient(); let scoreboardToken = scoreboardTokens.get(eventId); @@ -31,6 +36,7 @@ export async function notify({ eventId, gameId }: NotifyArgs): Promise { const topics = [channelNames.admin(eventId)]; if (scoreboardToken) topics.push(channelNames.scoreboard(scoreboardToken)); if (gameId) topics.push(channelNames.game(gameId)); + if (buzzer) topics.push(channelNames.buzzer(eventId)); await Promise.all( topics.map(async (topic) => { diff --git a/src/lib/scoreboard-sounds.test.ts b/src/lib/scoreboard-sounds.test.ts index 1a669ec..733e3f7 100644 --- a/src/lib/scoreboard-sounds.test.ts +++ b/src/lib/scoreboard-sounds.test.ts @@ -18,6 +18,7 @@ const payload = (overrides: Partial = {}): ScoreboardPayload reveal: null, teams: [team("a", 1), team("b", 2), team("c", 3)], timer: null, + buzzer: null, updatedAt: "2026-10-03T12:00:00.000Z", ...overrides, }); @@ -65,6 +66,25 @@ describe("soundsForUpdate", () => { it("is silent when the reveal finishes and the normal scoreboard returns", () => { expect(soundsForUpdate(payload(reveal(["a", "b", "c"])), payload(), motion)).toEqual([]); }); + + it("dings for a new first buzz only", () => { + const buzzed = (...ids: string[]): Partial => ({ + buzzer: { + open: true, + buzzes: ids.map((teamId, index) => ({ teamId, name: teamId, color: "#000000", afterFirstMs: index * 100 })), + }, + }); + expect(soundsForUpdate(payload(buzzed()), payload(buzzed("a")), motion)).toEqual(["buzzIn"]); + // A second team buzzing, or nothing changing, is silent. + expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a", "b")), motion)).toEqual([]); + // Cleared for the next question: silent, then a ding again even for the same team. + expect(soundsForUpdate(payload(buzzed("a", "b")), payload(buzzed()), motion)).toEqual([]); + expect(soundsForUpdate(payload(buzzed()), payload(buzzed("a")), motion)).toEqual(["buzzIn"]); + // Cleared and re-buzzed by another team between two fetches. + expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("b")), motion)).toEqual(["buzzIn"]); + // Moving to a game without buzzers. + expect(soundsForUpdate(payload(buzzed("a")), payload(), motion)).toEqual([]); + }); }); describe("soundForCountdown", () => { diff --git a/src/lib/scoreboard-sounds.ts b/src/lib/scoreboard-sounds.ts index 62539ad..43449e1 100644 --- a/src/lib/scoreboard-sounds.ts +++ b/src/lib/scoreboard-sounds.ts @@ -32,6 +32,11 @@ export function soundsForUpdate( else sounds.push(isWinner ? "revealWinner" : "reveal"); } } + + // A new first buzz. Later buzzes are silent: the room only needs to hear who won the race. + const firstBuzz = after.buzzer?.buzzes[0]?.teamId; + if (firstBuzz && firstBuzz !== before.buzzer?.buzzes[0]?.teamId) sounds.push("buzzIn"); + return sounds; } diff --git a/src/lib/scoreboard.server.ts b/src/lib/scoreboard.server.ts index f8819da..da935b6 100644 --- a/src/lib/scoreboard.server.ts +++ b/src/lib/scoreboard.server.ts @@ -1,5 +1,6 @@ import "server-only"; +import { buzzerView } from "@/lib/buzzer"; import type { Tables } from "@/lib/database.types"; import { loadEventData, toScoringInput, type EventData } from "@/lib/queries/scoring"; import { computeEvent } from "@/lib/scoring"; @@ -22,6 +23,11 @@ export function toScoreboardInput(data: EventData): ScoreboardInput { teams: data.teams, games: data.games, standings: computeEvent(toScoringInput(data)).standings, + buzzer: buzzerView( + data.games.find((game) => game.id === event.current_game_id), + data.teams, + data.buzzes, + ), now: new Date().toISOString(), }; } diff --git a/src/lib/scoreboard.test.ts b/src/lib/scoreboard.test.ts index 0532c82..4b62967 100644 --- a/src/lib/scoreboard.test.ts +++ b/src/lib/scoreboard.test.ts @@ -42,6 +42,7 @@ const input = (event: Partial = {}): ScoreboardInput = standing("eagles", 2, 20), standing("sharks", 4, 5), ], + buzzer: null, now: "2026-10-03T12:00:00.000Z", }); @@ -180,3 +181,22 @@ describe("timer", () => { expect(hasNoScores(payload.teams)).toBe(true); }); }); + +describe("buzzer", () => { + const buzzer = { + open: true, + buzzes: [{ teamId: "sharks", name: "Sharks", color: "#2EC4B6", afterFirstMs: 0 }], + }; + + it("is null when the current game has no buzzers", () => { + expect(buildScoreboardPayload(input()).buzzer).toBeNull(); + }); + + it("is sent whatever the score visibility, without adding any scores", () => { + for (const visibility of ["visible", "frozen", "hidden"] as const) { + const payload = buildScoreboardPayload({ ...input({ visibility }), buzzer }); + expect(payload.buzzer).toEqual(buzzer); + if (visibility !== "visible") expect(hasNoScores(payload.teams)).toBe(true); + } + }); +}); diff --git a/src/lib/scoreboard.ts b/src/lib/scoreboard.ts index f958014..98ac781 100644 --- a/src/lib/scoreboard.ts +++ b/src/lib/scoreboard.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { BuzzerView } from "@/lib/buzzer"; import type { Standing } from "@/lib/scoring"; import { timerState, type TimerRow, type TimerState } from "@/lib/timer"; @@ -25,6 +26,8 @@ export type ScoreboardPayload = { teams: ScoreboardTeam[]; /** Null while the host has the timer hidden. Not a score, so it ignores frozen/hidden. */ timer: TimerState | null; + /** Null unless the current game uses buzzers. Not a score either, so it is always live. */ + buzzer: BuzzerView | null; /** The server's clock when this was built; screens use it to correct their own clocks. */ updatedAt: string; }; @@ -44,6 +47,8 @@ export type ScoreboardInput = { games: Array<{ id: string; title: string }>; /** From the scoring engine, already sorted for display. */ standings: Standing[]; + /** The current game's buzzer state (see buzzerView), or null when it has no buzzers. */ + buzzer: BuzzerView | null; now: string; }; @@ -128,6 +133,7 @@ export function buildScoreboardPayload(input: ScoreboardInput): ScoreboardPayloa : null, teams: teamsFor(input), timer: event.timer.timer_visible ? timerState(event.timer, Date.parse(input.now)) : null, + buzzer: input.buzzer, updatedAt: input.now, }; } diff --git a/src/lib/sound.ts b/src/lib/sound.ts index 990dbce..794a7cc 100644 --- a/src/lib/sound.ts +++ b/src/lib/sound.ts @@ -10,6 +10,7 @@ export type SoundName = | "revealWinner" // drum-roll, then a fanfare for first place | "crash" // a team appears with no drum-roll (reduced motion) | "game" // a new game starts + | "buzzIn" // a team is first on the buzzer | "chime"; // confirmation when sound is switched on /** Matches the scoreboard's reveal animation: the cover lifts 70% into 1800ms. */ @@ -135,6 +136,12 @@ export function createSoundPlayer(): SoundPlayer { tone(start + index * 0.11, frequency * 2, 0.4, 0.08, "sine"); }); }, + buzzIn: (start) => { + // A bright two-note "ding-ding", nothing like the low time's-up buzzer. + tone(start, 987.77, 0.14, 0.4, "square"); + tone(start + 0.11, 1318.51, 0.45, 0.4, "square"); + tone(start + 0.11, 2637.02, 0.3, 0.1, "sine"); + }, chime: (start) => { tone(start, 783.99, 0.35, 0.3, "sine"); tone(start + 0.12, 1046.5, 0.5, 0.3, "sine"); diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts index 6b970e6..99694f3 100644 --- a/src/lib/tokens.ts +++ b/src/lib/tokens.ts @@ -7,5 +7,6 @@ export const tokenSchema = z.string().regex(/^[0-9a-f]{32}$/, "Invalid link"); export const scoreboardTokenSchema = tokenSchema; export const judgeTokenSchema = tokenSchema; export const openTokenSchema = tokenSchema; +export const buzzerTokenSchema = tokenSchema; export const idSchema = z.uuid(); diff --git a/src/lib/validation/admin.test.ts b/src/lib/validation/admin.test.ts index 582c326..4e0599b 100644 --- a/src/lib/validation/admin.test.ts +++ b/src/lib/validation/admin.test.ts @@ -13,6 +13,7 @@ const game: GameDraft = { pointsMode: "raw", multiplier: 1, placementPoints: [10, 7, 5, 3], + buzzerEnabled: false, adminNotes: "", judgeNotes: "", criteria: [criterion], diff --git a/src/lib/validation/admin.ts b/src/lib/validation/admin.ts index c999a2e..3d92e8f 100644 --- a/src/lib/validation/admin.ts +++ b/src/lib/validation/admin.ts @@ -78,6 +78,7 @@ export const gameSchema = z.object({ .array(z.number().int("Placement points must be whole numbers.").min(0).max(100000)) .min(1, "Add at least one placement value, like 10, 7, 5, 3.") .max(30), + buzzerEnabled: z.boolean(), adminNotes: optionalText(4000, "Admin notes"), judgeNotes: optionalText(4000, "Judge notes"), criteria: z.array(criterionSchema).min(1, "A game needs at least one criterion.").max(20), diff --git a/supabase/migrations/0003_buzzer.sql b/supabase/migrations/0003_buzzer.sql new file mode 100644 index 0000000..504a3dc --- /dev/null +++ b/supabase/migrations/0003_buzzer.sql @@ -0,0 +1,44 @@ +-- Game Night Live — team buzzers. +-- Each team gets its own buzzer link; a game opts in with buzzer_enabled. +-- Run after 0002_timer.sql. +-- +-- A buzz is one row per (game, team). The database's clock stamps it, so the order never +-- depends on a phone's clock or on which server instance handled the request. +-- Clearing the buzzers for the next question = deleting the game's rows. + +alter table games + add column buzzer_enabled boolean not null default false; + +-- new_token() is volatile, so every existing team gets its own token here. +alter table teams + add column buzzer_token text not null unique default new_token(); + +create table buzzes ( + id uuid primary key default gen_random_uuid(), + game_id uuid not null references games(id) on delete cascade, + team_id uuid not null references teams(id) on delete cascade, + -- clock_timestamp(), not now(): the real moment of the insert, not the transaction's start. + buzzed_at timestamptz not null default clock_timestamp(), + constraint buzzes_one_per_team unique (game_id, team_id) +); + +-- the team must be in the same event as the game +create or replace function validate_buzz() returns trigger +language plpgsql as $$ +begin + if (select event_id from teams where id = new.team_id) + is distinct from (select event_id from games where id = new.game_id) then + raise exception 'team does not belong to the game''s event'; + end if; + return new; +end $$; + +create trigger buzzes_validate +before insert or update on buzzes +for each row execute function validate_buzz(); + +-- Same rule as every other table: owner only, anon has no policies. +alter table buzzes enable row level security; + +create policy owner_all on buzzes for all to authenticated + using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));