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:
+25
-4
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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're offline. The buzzer won'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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -13,6 +13,7 @@ const game: GameDraft = {
|
||||
pointsMode: "raw",
|
||||
multiplier: 1,
|
||||
placementPoints: [10, 7, 5, 3],
|
||||
buzzerEnabled: false,
|
||||
adminNotes: "",
|
||||
judgeNotes: "",
|
||||
criteria: [criterion],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)));
|
||||
Reference in New Issue
Block a user