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."
+1 -1
View File
@@ -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;
+7 -5
View File
@@ -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<Actio
if (error) return dbFail("Deleting the team", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
await notify({ eventId, buzzer: true });
return ok;
}
@@ -261,6 +261,7 @@ async function insertGameCopy(
points_mode: game.points_mode,
multiplier: game.multiplier,
placement_points: game.placement_points,
buzzer_enabled: game.buzzer_enabled,
admin_notes: game.admin_notes,
judge_notes: game.judge_notes,
})
@@ -345,6 +346,7 @@ export async function saveGame(eventId: string, gameId: string, input: GameDraft
points_mode: game.pointsMode,
multiplier: game.multiplier,
placement_points: game.placementPoints,
buzzer_enabled: game.buzzerEnabled,
admin_notes: game.adminNotes || null,
judge_notes: game.judgeNotes || null,
})
@@ -389,7 +391,7 @@ export async function saveGame(eventId: string, gameId: string, input: GameDraft
if (failed?.error) return dbFail("Saving the criteria", failed.error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
await notify({ eventId, buzzer: true });
return ok;
}
@@ -423,7 +425,7 @@ export async function deleteGame(eventId: string, gameId: string): Promise<Actio
if (error) return dbFail("Deleting the game", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
await notify({ eventId, buzzer: true });
return ok;
}
+1 -1
View File
@@ -63,7 +63,7 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
<section className="rounded-lg border border-ink/15 bg-white p-4">
<h2 className="text-xl font-bold">Share links</h2>
<SharePanel scoreboardToken={event.scoreboard_token} games={games} />
<SharePanel scoreboardToken={event.scoreboard_token} teams={teams} games={games} />
</section>
</div>
);
+23 -2
View File
@@ -54,7 +54,7 @@ export async function setCurrentGame(eventId: string, gameId: string): Promise<A
if (!event.data?.length) return BAD_ID;
revalidatePath(livePath(eventId));
await notify({ eventId, gameId });
await notify({ eventId, gameId, buzzer: true });
return ok;
}
@@ -74,7 +74,28 @@ export async function setGameStatus(
if (error) return dbFail("Updating the game", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId, gameId });
// Marking a game done closes its buzzers, so the teams' phones need to know.
await notify({ eventId, gameId, buzzer: true });
return ok;
}
// ---------- buzzers ----------
/** Clears who buzzed, ready for the next question. */
export async function clearBuzzes(eventId: string, gameId: string): Promise<ActionResult> {
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;
}
+28
View File
@@ -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 });
}
}
+17
View File
@@ -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 });
}
}
+18
View File
@@ -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 <BuzzerMessage>{result.error}</BuzzerMessage>;
return <BuzzerView token={token} initial={result.payload} />;
}
+22
View File
@@ -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;
}
}
+50
View File
@@ -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 (
<div className="rounded-md border border-ink/15 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-lg font-bold">Buzzers</h3>
<Button
variant="secondary"
disabled={pending || buzzes.length === 0}
onClick={() => run(() => clearBuzzes(eventId, gameId))}
>
Clear buzzers (B)
</Button>
</div>
{buzzes.length === 0 ? (
<p role="status" className="mt-1">
{buzzer.open ? "Waiting for a buzz…" : "Closed — the game is done."}
</p>
) : (
<ol role="status" className="mt-2 flex flex-col gap-1">
{buzzes.map((entry, index) => (
<li
key={entry.teamId}
className={`flex items-center gap-2 rounded-md px-2 ${index === 0 ? "min-h-11 bg-floodlight text-xl font-bold" : ""}`}
>
<span className="w-5 tabular-nums">{index + 1}</span>
<span aria-hidden className="inline-block size-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<span className="text-sm tabular-nums">{index === 0 ? "first" : formatBuzzGap(entry.afterFirstMs)}</span>
</li>
))}
</ol>
)}
<ErrorText>{error}</ErrorText>
</div>
);
}
+13 -1
View File
@@ -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 && (
<BuzzerPanel eventId={event.id} gameId={currentGame.id} buzzer={currentGame.buzzer} />
)}
{/* 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" && (
+16
View File
@@ -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
/>
</Field>
)}
<label className="flex min-h-11 items-start gap-3 sm:col-span-2">
<input
type="checkbox"
checked={draft.buzzerEnabled}
onChange={(event) => change({ buzzerEnabled: event.target.checked })}
className="mt-1 size-5 shrink-0 accent-ink"
/>
<span>
<span className="font-bold">Teams buzz in</span>
<span className="block text-sm text-ink/70">
Each team buzzes from its own link (under Share links). The control room and the
scoreboard show who was first. Scoring stays as set above.
</span>
</span>
</label>
</div>
<fieldset className="flex flex-col gap-3">
+13 -1
View File
@@ -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",
);
});
});
+50 -5
View File
@@ -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<GameWithCriteria, "title" | "judges">)
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<Tables<"teams">, "id" | "name" | "buzzer_token">;
/** Plain text for pasting into WhatsApp: one line per team. */
export function buzzerLinksText(teams: Array<Pick<BuzzerTeam, "name" | "buzzer_token">>): 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
/>
</div>
{/* One link per team for the whole night; it only does something during a buzzer game. */}
{usesBuzzers && (
<div className="flex flex-col gap-2 border-t border-ink/15 pt-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-40 flex-1 text-lg font-bold">Team buzzers</h3>
{teams.length > 0 && <CopyAllButton text={buzzerLinksText(teams)} label="Copy all buzzer links" />}
</div>
{teams.length === 0 ? (
<p>No teams yet. Add teams above and each gets a buzzer link.</p>
) : (
<ul className="flex flex-col gap-2">
{teams.map((team) => (
<li key={team.id} className="flex flex-wrap items-center gap-2">
<span className="min-w-32 flex-1">{team.name}</span>
<ShareLink
url={`${site}/b/${team.buzzer_token}`}
copyLabel="Copy buzzer link"
qrLabel={`QR code for the ${team.name} buzzer`}
/>
</li>
))}
</ul>
)}
</div>
)}
{judgedGames.length === 0 ? (
<p>No judged games yet. Set a game to “Judged” to give judges their own links.</p>
) : (
@@ -50,7 +93,9 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string
<div key={game.id} className="flex flex-col gap-2 border-t border-ink/15 pt-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-40 flex-1 text-lg font-bold">{game.title}</h3>
{activeJudges.length > 0 && <CopyAllButton text={judgeLinksText(game)} />}
{activeJudges.length > 0 && (
<CopyAllButton text={judgeLinksText(game)} label="Copy all judge links" />
)}
</div>
{activeJudges.length === 0 && !game.open_link_enabled && (
<p>No judges yet. Open this game below to add judges or turn on its open link.</p>
@@ -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}
</Button>
{state === "failed" && (
<div className="basis-full">
+170
View File
@@ -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<string | null>(null);
const [offline, setOffline] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(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 <BuzzerMessage>{linkOff}</BuzzerMessage>;
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 (
<main
className="mx-auto flex min-h-dvh max-w-xl flex-col items-center gap-4 p-4 pb-8 transition-colors"
// First in: the whole screen turns the team's colour, so the table sees it at a glance.
style={won ? { backgroundColor: team.color, color: textColor } : undefined}
>
<header className="w-full text-center">
<h1 className="flex items-center justify-center gap-2 font-display text-4xl font-bold">
{!won && (
<span aria-hidden className="inline-block size-6 rounded-full" style={{ backgroundColor: team.color }} />
)}
{team.name}
</h1>
<p className="text-lg">{currentGame ? currentGame.title : payload.eventName}</p>
</header>
{offline && (
<p role="status" className="w-full rounded-md border-2 border-alert bg-white p-3 font-bold text-ink">
You&apos;re offline. The buzzer won&apos;t work until the connection is back.
</p>
)}
<div className="flex w-full flex-1 items-center justify-center">
<button
type="button"
disabled={!canBuzz}
onPointerDown={() => void press()}
onClick={() => void press()}
className={`flex aspect-square w-full max-w-sm touch-manipulation items-center justify-center rounded-full border-8 font-display text-7xl font-bold shadow-xl transition-transform select-none active:scale-95 disabled:shadow-none ${
position < 0 ? "disabled:opacity-40" : ""
} ${won ? "border-current" : "border-ink"}`}
style={{ backgroundColor: team.color, color: textColor }}
>
{position >= 0 ? ordinal(position + 1) : "Buzz"}
</button>
</div>
<p role="status" className="min-h-16 text-center text-2xl font-bold">
{error ? <span className={won ? "" : "text-alert-strong"}>{error}</span> : status}
</p>
</main>
);
}
export function BuzzerMessage({ 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>
);
}
/** 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();
};
}, []);
}
+27 -1
View File
@@ -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 && <ScoreboardTimer timer={payload.timer} serverNow={payload.updatedAt} play={play} />}
{payload.buzzer && payload.buzzer.buzzes.length > 0 && (
// Keyed by the first team so the pop replays when a new question has a new winner.
<BuzzBanner key={payload.buzzer.buzzes[0].teamId} buzzes={payload.buzzer.buzzes} />
)}
{hidden && (
<p className="self-center rounded-full border-2 border-alert px-[2.5vw] py-[1vh] text-[clamp(1rem,3.2vh,2.5rem)] font-bold">
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 (
<div
role="status"
className="buzz-pop flex shrink-0 items-baseline justify-center gap-[2vw] rounded-[1vh] px-[3vw] py-[1.5vh] font-display leading-none font-bold"
style={{ backgroundColor: first.color, color: readableTextColor(first.color) }}
>
<span className="truncate text-[clamp(1.5rem,9vh,8rem)]">{first.name} buzzed!</span>
{rest.length > 0 && (
<span className="truncate text-[clamp(1rem,3.5vh,3rem)]">
then {rest.map((entry) => entry.name).join(", ")}
</span>
)}
</div>
);
}
/** True after `ms` without mouse or keyboard activity (hides the cursor on the projector). */
function useIdle(ms: number) {
const [idle, setIdle] = useState(false);
+93
View File
@@ -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<typeof createAdminClient>;
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<BuzzerLookup> {
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<BuzzerLookup> {
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;
}
+71
View File
@@ -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",
]);
});
});
+83
View File
@@ -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"}`;
}
+42
View File
@@ -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;
+4
View File
@@ -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) => ({
+12 -2
View File
@@ -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<Tables<
* after the token has been validated).
*/
export async function loadEventData(supabase: Client, event: Tables<"events">): Promise<EventData> {
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<Tables<"judges">[], { 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,
};
}
+1
View File
@@ -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}`,
};
+8 -2
View File
@@ -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<string, string>();
@@ -14,7 +19,7 @@ const scoreboardTokens = new Map<string, string>();
* 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<void> {
export async function notify({ eventId, gameId, buzzer }: NotifyArgs): Promise<void> {
try {
const supabase = createAdminClient();
let scoreboardToken = scoreboardTokens.get(eventId);
@@ -31,6 +36,7 @@ export async function notify({ eventId, gameId }: NotifyArgs): Promise<void> {
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) => {
+20
View File
@@ -18,6 +18,7 @@ const payload = (overrides: Partial<ScoreboardPayload> = {}): 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<ScoreboardPayload> => ({
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", () => {
+5
View File
@@ -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;
}
+6
View File
@@ -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(),
};
}
+20
View File
@@ -42,6 +42,7 @@ const input = (event: Partial<ScoreboardInput["event"]> = {}): 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);
}
});
});
+6
View File
@@ -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,
};
}
+7
View File
@@ -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");
+1
View File
@@ -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();
+1
View File
@@ -13,6 +13,7 @@ const game: GameDraft = {
pointsMode: "raw",
multiplier: 1,
placementPoints: [10, 7, 5, 3],
buzzerEnabled: false,
adminNotes: "",
judgeNotes: "",
criteria: [criterion],
+1
View File
@@ -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),
+44
View File
@@ -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)));