From bf5b6194f6369328bcc7231a9e49be5123d20b9a Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Sat, 3 Oct 2026 22:14:10 +0200 Subject: [PATCH] Add scoreboard countdown timer and sound effects - Timer: one per event, set from the control room (duration, presets, start/pause/resume/reset, show/hide, T shortcut). Rules are pure functions in src/lib/timer.ts; the server's clock sets the end time and each screen counts down locally, corrected for its own clock. Needs supabase/migrations/0002_timer.sql. - Sound effects on the scoreboard, synthesised with Web Audio (no files): drum-roll + crash per revealed team, fanfare for first place, a chime on game change, ticks for the last 10 seconds and a buzzer at zero. Triggers are pure functions comparing consecutive payloads. - 'Turn sound on' button on the scoreboard, since browsers block audio until a click; the choice is remembered across reloads. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 17 +- docs/BUILD_PLAN.md | 8 +- docs/DATA_MODEL.md | 3 +- docs/PRD.md | 23 ++- src/app/admin/live-actions.ts | 65 +++++++ src/app/globals.css | 29 +++ src/components/admin/control-room.tsx | 37 +++- src/components/admin/timer-panel.tsx | 143 +++++++++++++++ .../scoreboard/scoreboard-timer.tsx | 49 ++++++ src/components/scoreboard/scoreboard.tsx | 27 +++ src/lib/database.types.ts | 12 ++ src/lib/hooks/use-countdown.ts | 30 ++++ src/lib/hooks/use-sound.ts | 73 ++++++++ src/lib/live-view.ts | 8 +- src/lib/scoreboard-sounds.test.ts | 95 ++++++++++ src/lib/scoreboard-sounds.ts | 53 ++++++ src/lib/scoreboard.server.ts | 1 + src/lib/scoreboard.test.ts | 31 ++++ src/lib/scoreboard.ts | 6 + src/lib/sound.ts | 165 ++++++++++++++++++ src/lib/timer.test.ts | 98 +++++++++++ src/lib/timer.ts | 83 +++++++++ supabase/migrations/0002_timer.sql | 14 ++ 23 files changed, 1056 insertions(+), 14 deletions(-) create mode 100644 src/components/admin/timer-panel.tsx create mode 100644 src/components/scoreboard/scoreboard-timer.tsx create mode 100644 src/lib/hooks/use-countdown.ts create mode 100644 src/lib/hooks/use-sound.ts create mode 100644 src/lib/scoreboard-sounds.test.ts create mode 100644 src/lib/scoreboard-sounds.ts create mode 100644 src/lib/sound.ts create mode 100644 src/lib/timer.test.ts create mode 100644 src/lib/timer.ts create mode 100644 supabase/migrations/0002_timer.sql diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d59a227..fd034e0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -59,12 +59,27 @@ type ScoreboardPayload = { rank?: number; total?: number; // v1 sends totals only; no per-game points. }>; - updatedAt: string; + // null while the host has the timer hidden; sent regardless of score visibility + timer: { + status: 'idle' | 'running' | 'paused' | 'finished'; + remainingMs: number; // as of updatedAt + endsAt: string | null; // set while running + durationMs: number; + } | null; + updatedAt: string; // server clock; screens use it to correct their own }; ``` During reveal mode, teams not yet revealed are sent without rank/total, alphabetically and before the revealed ones, so their order leaks nothing. When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live. A missing or invalid snapshot is treated as hidden, never as live. +## Timer and sound +- Timer state lives on `events` (see DATA_MODEL.md). `src/lib/timer.ts` holds the rules as pure + functions; server actions apply them using the server's clock. Screens count down locally + between pings from `endsAt`, offset by `updatedAt` minus their own clock. +- Sound effects are synthesised in `src/lib/sound.ts` with the Web Audio API. Which sound a + 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. + ## Folder layout ``` src/ diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index f2c0937..907b8c3 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -59,5 +59,11 @@ human a short "how to test this manually" checklist. Don't start the next phase - Deploy to Vercel; set env vars; set Supabase auth redirect URLs to the Vercel domain. **Done when:** full dry run on the deployed URL: laptop admin + projector scoreboard + 2 phone judges. +## Phase 9 — Timer + sound effects +- Apply `supabase/migrations/0002_timer.sql`, then `npm run gen:types`. +- Timer rules in `src/lib/timer.ts` (tested); control-room panel (set, start/pause/reset, show/hide, `T`); scoreboard display. +- 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. + ## Stretch (only if asked) -Participants within teams · co-host admins · countdown timer on scoreboard · sound effects on reveal · CSV export of results. +Participants within teams · co-host admins · CSV export of results. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index d65488f..7bdc190 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -4,7 +4,7 @@ Full SQL in `supabase/migrations/0001_init.sql`. | Table | Purpose | Key columns | |---|---|---| -| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count` | +| `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` | | `criteria` | Scoring blocks per game | `label`, `description`, `input_type`, `min_value`, `max_value`, `step`, `weight` | @@ -16,4 +16,5 @@ Full SQL in `supabase/migrations/0001_init.sql`. - `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`). - `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time. diff --git a/docs/PRD.md b/docs/PRD.md index 7ac0dba..be687c0 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -62,7 +62,7 @@ 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, `?` help. +- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer, `?` help. ### 4.3 Scoreboard (`/s/[token]`) - Projector-first, 16:9, readable from the back of a hall. @@ -89,6 +89,25 @@ This is the screen used during the night. Must be fast and hard to mess up. - Open link (`/jo/[token]`): enter name → creates judge → redirects to personal link and stores it in localStorage so a refresh keeps them in. +### 4.5 Timer +One countdown timer per event, run from the control room; not tied to a game. +- Set a time (minutes + seconds, or presets 30s / 1 min / 2 min / 5 min). Setting a time resets the timer. +- Start, pause, resume, reset. Show or hide it on the scoreboard; starting it also shows it. +- The scoreboard shows it large above the standings. It is not a score, so it shows whether + scores are visible, frozen or hidden. +- The last 10 seconds are highlighted; at zero it shows "Time's up" until reset. +- The server's clock decides when it ends, so every screen agrees even if a device clock is wrong. +- Keyboard shortcut in the control room: `T` start/pause. + +### 4.6 Sound effects (scoreboard only) +Played by the scoreboard page, so they come out of the projector PC's speakers. +- Reveal: a drum-roll, then a crash as each team appears; a fanfare for first place. +- Game transition: a chime when the current game changes. +- 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. +- Sounds are synthesised in the browser (no audio files). With reduced motion the drum-roll is skipped. + ## 5. Non-functional - Live latency target < 1s on normal Wi-Fi. - Works with ~10 teams, ~20 games, ~10 judges per game without lag. @@ -98,4 +117,4 @@ This is the screen used during the night. Must be fast and hard to mess up. ## 6. Out of scope (v1) - Individual contestants within teams (stretch: `participants` per team). - Multiple admins / co-hosts (stretch). -- Timers/buzzers (stretch). +- Per-game timers and buzzers for contestants (stretch). The event countdown timer is in 4.5. diff --git a/src/app/admin/live-actions.ts b/src/app/admin/live-actions.ts index 39abee5..de82be4 100644 --- a/src/app/admin/live-actions.ts +++ b/src/app/admin/live-actions.ts @@ -9,6 +9,16 @@ import { loadEventData } from "@/lib/queries/scoring"; import { notify } from "@/lib/realtime"; import { buildFrozenSnapshot } from "@/lib/scoreboard"; import { toScoreboardInput } from "@/lib/scoreboard.server"; +import { + durationPatch, + MAX_TIMER_SECONDS, + MIN_TIMER_SECONDS, + pausePatch, + resetPatch, + startPatch, + type TimerPatch, + type TimerRow, +} from "@/lib/timer"; import { idSchema } from "@/lib/tokens"; import type { ActionResult } from "./actions"; @@ -282,3 +292,58 @@ async function updateReveal( await notify({ eventId }); return ok; } + +// ---------- countdown timer ---------- + +/** Loads the timer, asks `patchFor` what to change, saves it and pings the scoreboard. */ +async function updateTimer( + eventId: string, + patchFor: (row: TimerRow, nowMs: number) => TimerPatch, +): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId)) return BAD_ID; + + const { data: row, error: loadError } = await supabase + .from("events") + .select("timer_visible, timer_duration_ms, timer_remaining_ms, timer_ends_at") + .eq("id", eventId) + .maybeSingle(); + if (loadError) return dbFail("Updating the timer", loadError.message); + if (!row) return BAD_ID; + + // The server's clock decides when the timer ends, so every screen agrees. + const patch = patchFor(row, Date.now()); + if (Object.keys(patch).length > 0) { + const { error } = await supabase.from("events").update(patch).eq("id", eventId); + if (error) return dbFail("Updating the timer", error.message); + } + + revalidatePath(livePath(eventId)); + await notify({ eventId }); + return ok; +} + +/** Starts or resumes the countdown (and shows it on the scoreboard). */ +export async function startTimer(eventId: string): Promise { + return updateTimer(eventId, startPatch); +} + +export async function pauseTimer(eventId: string): Promise { + return updateTimer(eventId, pausePatch); +} + +export async function resetTimer(eventId: string): Promise { + return updateTimer(eventId, resetPatch); +} + +/** Sets how long the timer runs, and resets it to that time. */ +export async function setTimerDuration(eventId: string, seconds: number): Promise { + if (!Number.isFinite(seconds) || seconds < MIN_TIMER_SECONDS || seconds > MAX_TIMER_SECONDS) { + return fail("Set the timer to between 1 second and 24 hours."); + } + return updateTimer(eventId, () => durationPatch(seconds)); +} + +export async function setTimerVisible(eventId: string, visible: boolean): Promise { + return updateTimer(eventId, () => ({ timer_visible: visible === true })); +} diff --git a/src/app/globals.css b/src/app/globals.css index a9cad48..b9a895f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -90,3 +90,32 @@ body { display: none; } } + +/* Scoreboard timer: a beat on every second of the last ten, and a flashing "Time's up". */ +@keyframes timer-pulse { + from { + transform: scale(1.12); + } +} + +@keyframes timer-flash { + 50% { + opacity: 0.25; + } +} + +.timer-pulse { + display: inline-block; + animation: timer-pulse 400ms ease-out; +} + +.timer-flash { + animation: timer-flash 900ms ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .timer-pulse, + .timer-flash { + animation: none; + } +} diff --git a/src/components/admin/control-room.tsx b/src/components/admin/control-room.tsx index 09ced67..fb789d6 100644 --- a/src/components/admin/control-room.tsx +++ b/src/components/admin/control-room.tsx @@ -4,7 +4,13 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; -import { setCurrentGame, setGameStatus, setVisibility } from "@/app/admin/live-actions"; +import { + pauseTimer, + setCurrentGame, + setGameStatus, + setVisibility, + startTimer, +} from "@/app/admin/live-actions"; import { Button } from "@/components/ui/button"; import { formatScore } from "@/lib/criteria"; import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel"; @@ -16,6 +22,7 @@ import { ErrorText } from "./error-text"; import { JudgedPanel } from "./judged-panel"; import { ScoreboardPanel, VISIBILITY_ORDER } from "./scoreboard-panel"; import { ScoringGrid } from "./scoring-grid"; +import { TimerPanel } from "./timer-panel"; import { useAction } from "./use-action"; const statusIcon = { upcoming: "○", live: "▶", done: "✓" } as const; @@ -25,11 +32,12 @@ const shortcuts = [ ["N", "Next game"], ["P", "Previous game"], ["H", "Cycle what the room sees: visible → frozen → hidden"], + ["T", "Start or pause the timer"], ["?", "Show or hide this help"], ] as const; export function ControlRoom({ view }: { view: LiveView }) { - const { event, teams, games, currentGame, standings, reveal } = view; + const { event, teams, games, currentGame, standings, reveal, timer } = view; const router = useRouter(); const { pending, error, run } = useAction(); const [showHelp, setShowHelp] = useState(false); @@ -57,6 +65,12 @@ export function ControlRoom({ view }: { view: LiveView }) { run(() => setVisibility(event.id, next)); }, [event.id, event.visibility, pending, reveal.active, run]); + const toggleTimer = useCallback(() => { + if (pending) return; + // The server ignores a pause on a timer that has already run out, and starts it again instead. + run(() => (timer.status === "running" ? pauseTimer(event.id) : startTimer(event.id))); + }, [event.id, pending, run, timer.status]); + useEffect(() => { const onKeyDown = (keyEvent: KeyboardEvent) => { // Never steal keys from a field the host is typing in. @@ -68,12 +82,13 @@ export function ControlRoom({ view }: { view: LiveView }) { if (key === "n") goTo(nextGame?.id); else if (key === "p") goTo(previousGame?.id); else if (key === "h") cycleVisibility(); + else if (key === "t") toggleTimer(); 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]); + }, [cycleVisibility, goTo, nextGame?.id, previousGame?.id, toggleTimer]); const teamsById = new Map(teams.map((team) => [team.id, team])); @@ -118,11 +133,17 @@ export function ControlRoom({ view }: { view: LiveView }) { )} - {/* Full width and first, so the visibility toggle is on screen at every window size. */} -
-

Scoreboard — what the room sees

- -
+ {/* Full width and first, so these controls are on screen at every window size. */} +
+
+

Scoreboard — what the room sees

+ +
+
+

Timer

+ +
+