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 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 22:14:10 +02:00
co-authored by Claude Opus 5.5
parent f1b3a862f8
commit bf5b6194f6
23 changed files with 1056 additions and 14 deletions
+16 -1
View File
@@ -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/
+7 -1
View File
@@ -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.
+2 -1
View File
@@ -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.
+21 -2
View File
@@ -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.
+65
View File
@@ -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<ActionResult> {
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<ActionResult> {
return updateTimer(eventId, startPatch);
}
export async function pauseTimer(eventId: string): Promise<ActionResult> {
return updateTimer(eventId, pausePatch);
}
export async function resetTimer(eventId: string): Promise<ActionResult> {
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<ActionResult> {
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<ActionResult> {
return updateTimer(eventId, () => ({ timer_visible: visible === true }));
}
+29
View File
@@ -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;
}
}
+25 -4
View File
@@ -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 }) {
</dl>
)}
{/* Full width and first, so the visibility toggle is on screen at every window size. */}
{/* Full width and first, so these controls are on screen at every window size. */}
<div className="grid items-start gap-4 lg:grid-cols-2">
<section className={panel}>
<h2 className="mb-2 text-lg font-bold">Scoreboard — what the room sees</h2>
<ScoreboardPanel view={view} />
</section>
<section className={panel}>
<h2 className="mb-2 text-lg font-bold">Timer</h2>
<TimerPanel view={view} />
</section>
</div>
<div className="grid items-start gap-4 lg:grid-cols-[15rem_minmax(0,1fr)] xl:grid-cols-[15rem_minmax(0,1fr)_20rem]">
<nav aria-label="Games" className={panel}>
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import {
pauseTimer,
resetTimer,
setTimerDuration,
setTimerVisible,
startTimer,
} from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
import { inputClass } from "@/components/ui/field";
import { useCountdown } from "@/lib/hooks/use-countdown";
import type { LiveView } from "@/lib/live-view";
import { formatClock } from "@/lib/timer";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
const PRESETS = [
{ label: "30s", seconds: 30 },
{ label: "1 min", seconds: 60 },
{ label: "2 min", seconds: 120 },
{ label: "5 min", seconds: 300 },
];
const statusText = { idle: "Ready", running: "Running", paused: "Paused", finished: "Time's up" } as const;
/** The scoreboard's countdown timer: set a time, start/pause/reset, show or hide it. */
export function TimerPanel({ view }: { view: LiveView }) {
const { event, timer, serverNow } = view;
const { pending, error, run } = useAction();
const remainingMs = useCountdown(timer, serverNow);
const finished = timer.status === "finished" || (timer.status === "running" && remainingMs === 0);
const [minutes, setMinutes] = useState(String(Math.floor(timer.durationMs / 60000)));
const [seconds, setSeconds] = useState(String(Math.round((timer.durationMs % 60000) / 1000)));
const setDuration = (totalSeconds: number) => {
setMinutes(String(Math.floor(totalSeconds / 60)));
setSeconds(String(totalSeconds % 60));
run(() => setTimerDuration(event.id, totalSeconds));
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<p
role="timer"
className={`font-display text-6xl leading-none font-bold tabular-nums ${finished ? "text-alert-strong" : ""}`}
>
{formatClock(remainingMs)}
</p>
<p>
<strong>{finished ? statusText.finished : statusText[timer.status]}</strong>
<span className="block text-sm text-ink/70">
{timer.visible ? "Showing on the scoreboard" : "Hidden from the scoreboard"}
</span>
</p>
</div>
<div className="flex flex-wrap gap-2">
{timer.status === "running" && !finished ? (
<Button disabled={pending} onClick={() => run(() => pauseTimer(event.id))}>
Pause
</Button>
) : (
<Button disabled={pending} onClick={() => run(() => startTimer(event.id))}>
{timer.status === "paused" ? "Resume" : finished ? "Start again" : "Start"}
</Button>
)}
<Button
variant="secondary"
disabled={pending || timer.status === "idle"}
onClick={() => run(() => resetTimer(event.id))}
>
Reset
</Button>
<Button
variant="secondary"
aria-pressed={timer.visible}
disabled={pending}
onClick={() => run(() => setTimerVisible(event.id, !timer.visible))}
>
{timer.visible ? "Hide timer" : "Show timer"}
</Button>
</div>
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(submitEvent) => {
submitEvent.preventDefault();
setDuration((Number(minutes) || 0) * 60 + (Number(seconds) || 0));
}}
>
<label className="flex flex-col gap-1">
<span className="text-sm font-bold">Minutes</span>
<input
type="number"
inputMode="numeric"
min={0}
max={1440}
value={minutes}
onChange={(changeEvent) => setMinutes(changeEvent.target.value)}
className={`${inputClass} w-20`}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-bold">Seconds</span>
<input
type="number"
inputMode="numeric"
min={0}
max={59}
value={seconds}
onChange={(changeEvent) => setSeconds(changeEvent.target.value)}
className={`${inputClass} w-20`}
/>
</label>
<Button type="submit" variant="secondary" disabled={pending}>
Set time
</Button>
{PRESETS.map((preset) => (
<Button
key={preset.seconds}
variant="secondary"
size="sm"
disabled={pending}
onClick={() => setDuration(preset.seconds)}
>
{preset.label}
</Button>
))}
</form>
<p className="text-sm text-ink/70">
Setting a time resets the timer. Starting it also shows it. The scoreboard ticks for the last
10 seconds and buzzes at zero, if its sound is on.
</p>
<ErrorText>{error}</ErrorText>
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useRef } from "react";
import { useCountdown } from "@/lib/hooks/use-countdown";
import { soundForCountdown } from "@/lib/scoreboard-sounds";
import type { SoundName } from "@/lib/sound";
import { formatClock, secondsLeft, type TimerState } from "@/lib/timer";
/** The big countdown on the scoreboard, with ticks for the last ten seconds and a buzzer at zero. */
export function ScoreboardTimer({
timer,
serverNow,
play,
}: {
timer: TimerState;
serverNow: string;
play: (name: SoundName) => void;
}) {
const remainingMs = useCountdown(timer, serverNow);
const seconds = secondsLeft(remainingMs);
// The local clock reaches zero before the next server update says "finished".
const finished = timer.status === "finished" || (timer.status === "running" && seconds === 0);
const urgent = timer.status === "running" && seconds <= 10 && !finished;
const previousSeconds = useRef<number | null>(null);
useEffect(() => {
const sound = soundForCountdown(timer.status, previousSeconds.current, seconds);
previousSeconds.current = seconds;
if (sound) play(sound);
}, [seconds, timer.status, play]);
return (
<div
role="timer"
aria-label={finished ? "Time's up" : `${formatClock(remainingMs)} left`}
className={`flex items-baseline justify-center gap-[2vw] font-display leading-none font-bold tabular-nums ${
finished ? "text-alert" : urgent ? "text-floodlight" : "text-white"
}`}
>
{/* Re-keyed each second in the last ten so the pulse restarts on every tick. */}
<span key={urgent ? seconds : "steady"} className={`text-[16vh] ${urgent ? "timer-pulse" : ""}`}>
{formatClock(remainingMs)}
</span>
{finished && <span className="timer-flash text-[7vh]">Time&apos;s up</span>}
{timer.status === "paused" && <span className="text-[5vh] text-white/70">paused</span>}
</div>
);
}
+27
View File
@@ -4,10 +4,13 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import { readableTextColor } from "@/lib/color";
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
import { useSound } from "@/lib/hooks/use-sound";
import { channelNames } from "@/lib/realtime-channels";
import type { ScoreboardPayload, ScoreboardTeam } from "@/lib/scoreboard";
import { soundsForUpdate } from "@/lib/scoreboard-sounds";
import { CountUp } from "./count-up";
import { ScoreboardTimer } from "./scoreboard-timer";
const MOVE_MS = 600;
const IDLE_MS = 3000;
@@ -42,6 +45,17 @@ 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.
const sound = useSound();
const { play } = sound;
const previousPayload = useRef(initial);
useEffect(() => {
const before = previousPayload.current;
previousPayload.current = payload;
if (before === payload) return;
soundsForUpdate(before, payload, { reducedMotion: prefersReducedMotion() }).forEach(play);
}, [payload, play]);
const rowElements = useRef(new Map<string, HTMLElement>());
const lastTops = useRef(new Map<string, number>());
@@ -107,6 +121,8 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
)}
</header>
{payload.timer && <ScoreboardTimer timer={payload.timer} serverNow={payload.updatedAt} play={play} />}
{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
@@ -155,6 +171,17 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
/>
{connectionLabel[connection]}
</span>
{/* Stays visible while sound is off or waiting, so the host can't miss that it's silent. */}
<button
type="button"
aria-pressed={sound.state === "on"}
onClick={sound.state === "on" ? sound.turnOff : () => void sound.turnOn()}
className={`min-h-11 rounded-md border px-4 font-bold hover:bg-white/10 focus-visible:outline-floodlight ${
sound.state === "on" ? "border-white/30" : "border-floodlight text-floodlight"
} ${idle && sound.state === "on" ? "invisible" : ""}`}
>
{sound.state === "on" ? "Sound on" : sound.state === "waiting" ? "Click anywhere for sound" : "Turn sound on"}
</button>
<button
type="button"
onClick={toggleFullscreen}
+12
View File
@@ -118,6 +118,10 @@ export type Database = {
reveal_count: number;
scoreboard_token: string;
scoreboard_visibility: Database["public"]["Enums"]["scoreboard_visibility"];
timer_duration_ms: number;
timer_ends_at: string | null;
timer_remaining_ms: number | null;
timer_visible: boolean;
updated_at: string;
};
Insert: {
@@ -132,6 +136,10 @@ export type Database = {
reveal_count?: number;
scoreboard_token?: string;
scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"];
timer_duration_ms?: number;
timer_ends_at?: string | null;
timer_remaining_ms?: number | null;
timer_visible?: boolean;
updated_at?: string;
};
Update: {
@@ -146,6 +154,10 @@ export type Database = {
reveal_count?: number;
scoreboard_token?: string;
scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"];
timer_duration_ms?: number;
timer_ends_at?: string | null;
timer_remaining_ms?: number | null;
timer_visible?: boolean;
updated_at?: string;
};
Relationships: [
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useState } from "react";
import type { TimerState } from "@/lib/timer";
/**
* Milliseconds left on a timer, ticking locally while it runs.
* `serverNow` is the server's clock when `timer` was computed; the difference from this
* device's clock is applied so a projector PC with a wrong clock still counts down correctly.
*/
export function useCountdown(timer: TimerState | null, serverNow: string): number {
const [remainingMs, setRemainingMs] = useState(timer?.remainingMs ?? 0);
const running = timer?.status === "running" && timer.endsAt !== null;
const endsAt = timer?.endsAt ?? null;
useEffect(() => {
if (!running || !endsAt) return;
const clockOffset = Date.parse(serverNow) - Date.now();
const endsAtMs = Date.parse(endsAt);
const tick = () => setRemainingMs(Math.max(0, endsAtMs - (Date.now() + clockOffset)));
// Sync to the clock at once rather than 100ms late.
tick();
const interval = setInterval(tick, 100);
return () => clearInterval(interval);
}, [running, endsAt, serverNow]);
// When not running the server's value is exact; only a running timer needs the local clock.
return running ? remainingMs : (timer?.remainingMs ?? 0);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createSoundPlayer, type SoundName, type SoundPlayer } from "@/lib/sound";
const STORAGE_KEY = "gnl-scoreboard-sound";
const remember = (on: boolean) => {
try {
localStorage.setItem(STORAGE_KEY, on ? "on" : "off");
} catch {
// Storage blocked: the choice just isn't remembered across reloads.
}
};
/**
* Sound for the scoreboard. Browsers block audio until someone clicks the page, so:
* "off" → the host clicks "Turn sound on" → "on". After a reload the choice is remembered,
* but the browser needs one more click ("waiting") before it will play.
*/
export function useSound() {
const player = useRef<SoundPlayer | null>(null);
const [state, setState] = useState<"off" | "waiting" | "on">("off");
// Guards against a double start: one click can reach both the button and the page listener.
const starting = useRef(false);
const turnOn = useCallback(async () => {
if (starting.current) return;
starting.current = true;
player.current ??= createSoundPlayer();
const allowed = await player.current.resume();
setState(allowed ? "on" : "off");
if (allowed) {
remember(true);
// A confirmation the host can hear, so they know the projector's speakers work.
player.current.play("chime");
} else {
starting.current = false;
}
}, []);
const turnOff = useCallback(() => {
player.current?.close();
player.current = null;
starting.current = false;
remember(false);
setState("off");
}, []);
// After a reload with sound previously on, the first click anywhere re-enables it.
useEffect(() => {
let wanted = false;
try {
wanted = localStorage.getItem(STORAGE_KEY) === "on";
} catch {
// Storage blocked: stay off until the button is clicked.
}
if (!wanted) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage is only readable after mount
setState("waiting");
const onClick = () => void turnOn();
window.addEventListener("click", onClick, { once: true });
return () => window.removeEventListener("click", onClick);
}, [turnOn]);
useEffect(() => () => player.current?.close(), []);
const play = useCallback((name: SoundName) => player.current?.play(name), []);
return { state, play, turnOn, turnOff };
}
+7 -1
View File
@@ -9,6 +9,7 @@ import {
type Standing,
} from "@/lib/scoring";
import type { Visibility } from "@/lib/scoreboard";
import { timerState, type TimerState } from "@/lib/timer";
// The control room's data, shaped on the server so the client component holds no score math
// (CLAUDE.md rule 5) and never receives tokens or rows it doesn't display.
@@ -69,6 +70,9 @@ export type LiveView = {
/** The team the next "Reveal next" click will show, or null when all are revealed. */
next: { teamId: string; rank: number; total: number } | null;
};
timer: TimerState & { visible: boolean };
/** The server's clock when this view was built, for the timer's local countdown. */
serverNow: string;
teams: Array<{ id: string; name: string; color: string }>;
games: Array<Pick<LiveGame, "id" | "title" | "status">>;
/** Full detail for the current game only. */
@@ -91,7 +95,7 @@ export function revealState(active: boolean, count: number, standings: Standing[
};
}
export function buildLiveView(data: EventData): LiveView {
export function buildLiveView(data: EventData, now = new Date()): LiveView {
const result = computeEvent(toScoringInput(data));
const { event } = data;
const game = data.games.find((candidate) => candidate.id === event.current_game_id);
@@ -166,6 +170,8 @@ export function buildLiveView(data: EventData): LiveView {
currentGameId: currentGame?.id ?? null,
},
reveal: revealState(event.reveal_active, event.reveal_count, result.standings),
timer: { ...timerState(event, now.getTime()), visible: event.timer_visible },
serverNow: now.toISOString(),
teams: data.teams.map((team) => ({ id: team.id, name: team.name, color: team.color })),
games: data.games.map((candidate) => ({
id: candidate.id,
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import type { ScoreboardPayload, ScoreboardTeam } from "@/lib/scoreboard";
import { soundForCountdown, soundsForUpdate } from "./scoreboard-sounds";
const team = (id: string, rank?: number): ScoreboardTeam => ({
id,
name: id,
color: "#000000",
...(rank === undefined ? {} : { rank, total: 10 }),
});
const payload = (overrides: Partial<ScoreboardPayload> = {}): ScoreboardPayload => ({
eventName: "Night",
visibility: "visible",
currentGame: { id: "g1", title: "Quiz", index: 1, total: 2 },
reveal: null,
teams: [team("a", 1), team("b", 2), team("c", 3)],
timer: null,
updatedAt: "2026-10-03T12:00:00.000Z",
...overrides,
});
const reveal = (revealed: string[]): Partial<ScoreboardPayload> => ({
reveal: { active: true, revealedCount: revealed.length },
teams: ["a", "b", "c"].map((id, index) => team(id, revealed.includes(id) ? index + 1 : undefined)),
});
const motion = { reducedMotion: false };
describe("soundsForUpdate", () => {
it("is silent when nothing relevant changed", () => {
expect(soundsForUpdate(payload(), payload(), motion)).toEqual([]);
expect(soundsForUpdate(payload(), payload({ teams: [team("b", 1), team("a", 2), team("c", 3)] }), motion)).toEqual([]);
});
it("chimes when the current game changes, including the first game", () => {
const next = payload({ currentGame: { id: "g2", title: "Karaoke", index: 2, total: 2 } });
expect(soundsForUpdate(payload(), next, motion)).toEqual(["game"]);
expect(soundsForUpdate(payload({ currentGame: null }), payload(), motion)).toEqual(["game"]);
expect(soundsForUpdate(payload(), payload({ currentGame: null }), motion)).toEqual([]);
});
it("is silent when a reveal starts with nobody revealed", () => {
expect(soundsForUpdate(payload(), payload(reveal([])), motion)).toEqual([]);
});
it("plays the drum-roll for each newly revealed team", () => {
expect(soundsForUpdate(payload(reveal([])), payload(reveal(["c"])), motion)).toEqual(["reveal"]);
expect(soundsForUpdate(payload(reveal(["c"])), payload(reveal(["b", "c"])), motion)).toEqual(["reveal"]);
expect(soundsForUpdate(payload(reveal(["c"])), payload(reveal(["c"])), motion)).toEqual([]);
});
it("plays the fanfare when the last team (first place) appears", () => {
expect(soundsForUpdate(payload(reveal(["b", "c"])), payload(reveal(["a", "b", "c"])), motion)).toEqual(["revealWinner"]);
});
it("skips the drum-roll with reduced motion, where the row appears at once", () => {
const reduced = { reducedMotion: true };
expect(soundsForUpdate(payload(reveal([])), payload(reveal(["c"])), reduced)).toEqual(["crash"]);
expect(soundsForUpdate(payload(reveal(["b", "c"])), payload(reveal(["a", "b", "c"])), reduced)).toEqual(["revealWinner"]);
});
it("is silent when the reveal finishes and the normal scoreboard returns", () => {
expect(soundsForUpdate(payload(reveal(["a", "b", "c"])), payload(), motion)).toEqual([]);
});
});
describe("soundForCountdown", () => {
it("ticks once a second for the last ten seconds, sharper for the last three", () => {
expect(soundForCountdown("running", 12, 11)).toBeNull();
expect(soundForCountdown("running", 11, 10)).toBe("tick");
expect(soundForCountdown("running", 5, 4)).toBe("tick");
expect(soundForCountdown("running", 4, 3)).toBe("tickFinal");
expect(soundForCountdown("running", 2, 1)).toBe("tickFinal");
});
it("buzzes when it reaches zero", () => {
expect(soundForCountdown("running", 1, 0)).toBe("buzzer");
expect(soundForCountdown("finished", 1, 0)).toBe("buzzer");
});
it("is silent on first sight, so opening the page late doesn't buzz", () => {
expect(soundForCountdown("finished", null, 0)).toBeNull();
expect(soundForCountdown("running", null, 7)).toBeNull();
});
it("is silent when the second didn't go down, or the timer isn't running", () => {
expect(soundForCountdown("running", 7, 7)).toBeNull();
expect(soundForCountdown("idle", 5, 60)).toBeNull();
expect(soundForCountdown("paused", 8, 7)).toBeNull();
expect(soundForCountdown("idle", 60, 0)).toBeNull();
});
});
+53
View File
@@ -0,0 +1,53 @@
import type { ScoreboardPayload } from "@/lib/scoreboard";
import type { SoundName } from "@/lib/sound";
import type { TimerStatus } from "@/lib/timer";
// Which sound effects a change on the scoreboard should trigger. Pure, so the rules
// are testable without a browser; the scoreboard component just plays what this returns.
const revealedIds = (payload: ScoreboardPayload) =>
new Set(
payload.reveal?.active ? payload.teams.filter((team) => team.rank !== undefined).map((team) => team.id) : [],
);
/** Sounds for going from one payload to the next. */
export function soundsForUpdate(
before: ScoreboardPayload,
after: ScoreboardPayload,
options: { reducedMotion: boolean },
): SoundName[] {
const sounds: SoundName[] = [];
// A new current game (including the first one of the night).
if (after.currentGame && after.currentGame.id !== before.currentGame?.id) sounds.push("game");
if (after.reveal?.active) {
const already = revealedIds(before);
const newlyRevealed = [...revealedIds(after)].some((id) => !already.has(id));
if (newlyRevealed) {
// The last team to appear is first place: it gets the fanfare.
const isWinner = after.reveal.revealedCount >= after.teams.length;
// With reduced motion the row appears at once, so a drum-roll would trail behind it.
if (options.reducedMotion) sounds.push(isWinner ? "revealWinner" : "crash");
else sounds.push(isWinner ? "revealWinner" : "reveal");
}
}
return sounds;
}
/**
* Sound for the countdown reaching a new whole second. `previousSeconds` is what the clock
* showed a moment ago; null on the first look, so loading the page mid-countdown or after
* time is up stays silent.
*/
export function soundForCountdown(
status: TimerStatus,
previousSeconds: number | null,
seconds: number,
): SoundName | null {
if (previousSeconds === null || seconds >= previousSeconds) return null;
if (status !== "running" && status !== "finished") return null;
if (seconds === 0) return "buzzer";
if (status !== "running" || seconds > 10) return null;
return seconds <= 3 ? "tickFinal" : "tick";
}
+1
View File
@@ -17,6 +17,7 @@ export function toScoreboardInput(data: EventData): ScoreboardInput {
currentGameId: event.current_game_id,
revealActive: event.reveal_active,
revealCount: event.reveal_count,
timer: event,
},
teams: data.teams,
games: data.games,
+31
View File
@@ -22,6 +22,7 @@ const input = (event: Partial<ScoreboardInput["event"]> = {}): ScoreboardInput =
currentGameId: "g2",
revealActive: false,
revealCount: 0,
timer: { timer_visible: false, timer_duration_ms: 60_000, timer_remaining_ms: null, timer_ends_at: null },
...event,
},
teams: [
@@ -149,3 +150,33 @@ describe("reveal", () => {
expect(all.reveal?.revealedCount).toBe(4);
});
});
describe("timer", () => {
const running = {
timer_visible: true,
timer_duration_ms: 60_000,
timer_remaining_ms: null,
timer_ends_at: "2026-10-03T12:00:25.000Z",
};
it("is omitted while the host has it hidden", () => {
expect(buildScoreboardPayload(input()).timer).toBeNull();
expect(buildScoreboardPayload(input({ timer: { ...running, timer_visible: false } })).timer).toBeNull();
});
it("is sent with the time left as of the payload's own clock", () => {
const payload = buildScoreboardPayload(input({ timer: running }));
expect(payload.timer).toEqual({
status: "running",
remainingMs: 25_000,
endsAt: "2026-10-03T12:00:25.000Z",
durationMs: 60_000,
});
});
it("is still sent when scores are hidden", () => {
const payload = buildScoreboardPayload(input({ visibility: "hidden", timer: running }));
expect(payload.timer?.status).toBe("running");
expect(hasNoScores(payload.teams)).toBe(true);
});
});
+6
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { Standing } from "@/lib/scoring";
import { timerState, type TimerRow, type TimerState } from "@/lib/timer";
// The ONE place that decides what the public scoreboard may see (CLAUDE.md rule 3).
// Pure: rows in, payload out. The API route, the page and the freeze action all use it.
@@ -22,6 +23,9 @@ export type ScoreboardPayload = {
currentGame: { id: string; title: string; index: number; total: number } | null;
reveal: { active: boolean; revealedCount: number } | null;
teams: ScoreboardTeam[];
/** Null while the host has the timer hidden. Not a score, so it ignores frozen/hidden. */
timer: TimerState | null;
/** The server's clock when this was built; screens use it to correct their own clocks. */
updatedAt: string;
};
@@ -33,6 +37,7 @@ export type ScoreboardInput = {
currentGameId: string | null;
revealActive: boolean;
revealCount: number;
timer: TimerRow;
};
teams: Array<{ id: string; name: string; color: string }>;
/** In running order. */
@@ -122,6 +127,7 @@ export function buildScoreboardPayload(input: ScoreboardInput): ScoreboardPayloa
? { active: true, revealedCount: Math.min(Math.max(event.revealCount, 0), input.teams.length) }
: null,
teams: teamsFor(input),
timer: event.timer.timer_visible ? timerState(event.timer, Date.parse(input.now)) : null,
updatedAt: input.now,
};
}
+165
View File
@@ -0,0 +1,165 @@
// Scoreboard sound effects, synthesised with the Web Audio API: no audio files to host,
// license or load, and they work offline. Browsers only allow sound after a click on the
// page, so the scoreboard has a "Turn sound on" button that calls resume().
export type SoundName =
| "tick" // countdown, seconds 10–4
| "tickFinal" // countdown, seconds 3–1
| "buzzer" // time's up
| "reveal" // drum-roll, then a crash as the team appears
| "revealWinner" // drum-roll, then a fanfare for first place
| "crash" // a team appears with no drum-roll (reduced motion)
| "game" // a new game starts
| "chime"; // confirmation when sound is switched on
/** Matches the scoreboard's reveal animation: the cover lifts 70% into 1800ms. */
export const DRUM_ROLL_SECONDS = 1.26;
export type SoundPlayer = {
/** Must be called from a click or key press. Resolves to whether sound is now allowed. */
resume: () => Promise<boolean>;
play: (name: SoundName) => void;
close: () => void;
};
export function createSoundPlayer(): SoundPlayer {
let context: AudioContext | null = null;
let master: GainNode | null = null;
let noise: AudioBuffer | null = null;
const ensure = () => {
if (!context) {
context = new AudioContext();
master = context.createGain();
master.gain.value = 0.7;
master.connect(context.destination);
// One second of white noise, reused for drums, cymbals and whooshes.
noise = context.createBuffer(1, context.sampleRate, context.sampleRate);
const samples = noise.getChannelData(0);
for (let index = 0; index < samples.length; index += 1) samples[index] = Math.random() * 2 - 1;
}
return { context, master: master!, noise: noise! };
};
/** A pitched note with a quick attack and exponential decay. */
const tone = (
start: number,
frequency: number,
duration: number,
volume: number,
type: OscillatorType = "sine",
slideTo?: number,
) => {
const { context: audio, master: output } = ensure();
const oscillator = audio.createOscillator();
const gain = audio.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, start);
if (slideTo) oscillator.frequency.exponentialRampToValueAtTime(slideTo, start + duration);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(volume, start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain).connect(output);
oscillator.start(start);
oscillator.stop(start + duration + 0.05);
};
/** A burst of filtered noise: snare hits, cymbals. */
const burst = (start: number, duration: number, volume: number, filter: BiquadFilterType, frequency: number) => {
const { context: audio, master: output, noise: buffer } = ensure();
const source = audio.createBufferSource();
const shape = audio.createBiquadFilter();
const gain = audio.createGain();
source.buffer = buffer;
source.loop = true;
shape.type = filter;
shape.frequency.value = frequency;
gain.gain.setValueAtTime(volume, start);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
source.connect(shape).connect(gain).connect(output);
source.start(start);
source.stop(start + duration + 0.05);
};
const drumRoll = (start: number) => {
// Snare strokes that speed up and get louder into the hit.
let time = start;
while (time < start + DRUM_ROLL_SECONDS - 0.03) {
const progress = (time - start) / DRUM_ROLL_SECONDS;
burst(time, 0.06, 0.12 + 0.28 * progress, "bandpass", 1800);
tone(time, 190, 0.05, 0.08 + 0.12 * progress, "triangle");
time += 0.075 - 0.035 * progress;
}
};
const crash = (start: number) => {
burst(start, 1.1, 0.5, "highpass", 5000);
tone(start, 130, 0.35, 0.7, "sine", 45);
};
const fanfare = (start: number) => {
crash(start);
// C–E–G up to a held high C major chord.
[523.25, 659.25, 783.99].forEach((frequency, index) => {
tone(start + index * 0.14, frequency, 0.2, 0.3, "sawtooth");
tone(start + index * 0.14, frequency, 0.2, 0.3, "triangle");
});
[523.25, 659.25, 783.99, 1046.5].forEach((frequency) => {
tone(start + 0.42, frequency, 1.4, 0.22, "sawtooth");
tone(start + 0.42, frequency, 1.4, 0.25, "triangle");
});
burst(start + 0.42, 1.4, 0.35, "highpass", 6000);
};
const sounds: Record<SoundName, (start: number) => void> = {
tick: (start) => tone(start, 880, 0.09, 0.35, "square"),
tickFinal: (start) => tone(start, 1320, 0.16, 0.45, "square"),
buzzer: (start) => {
tone(start, 110, 1.1, 0.5, "sawtooth");
tone(start, 116.5, 1.1, 0.5, "sawtooth");
tone(start, 220, 1.1, 0.25, "square");
},
reveal: (start) => {
drumRoll(start);
crash(start + DRUM_ROLL_SECONDS);
},
revealWinner: (start) => {
drumRoll(start);
fanfare(start + DRUM_ROLL_SECONDS);
},
crash,
game: (start) => {
// A rising three-note chime: "next game".
[587.33, 783.99, 1174.66].forEach((frequency, index) => {
tone(start + index * 0.11, frequency, 0.7, 0.32, "sine");
tone(start + index * 0.11, frequency * 2, 0.4, 0.08, "sine");
});
},
chime: (start) => {
tone(start, 783.99, 0.35, 0.3, "sine");
tone(start + 0.12, 1046.5, 0.5, 0.3, "sine");
},
};
return {
async resume() {
try {
const { context: audio } = ensure();
if (audio.state !== "running") await audio.resume();
return audio.state === "running";
} catch {
return false;
}
},
play(name) {
// Before the first click the context is suspended; scheduling then would make every
// missed sound play at once when it resumes.
if (context?.state !== "running") return;
sounds[name](context.currentTime + 0.02);
},
close() {
void context?.close();
context = null;
},
};
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
durationPatch,
formatClock,
pausePatch,
resetPatch,
secondsLeft,
startPatch,
timerState,
type TimerRow,
} from "./timer";
const NOW = Date.parse("2026-10-03T18:00:00.000Z");
const at = (offsetMs: number) => new Date(NOW + offsetMs).toISOString();
const row = (overrides: Partial<TimerRow> = {}): TimerRow => ({
timer_visible: false,
timer_duration_ms: 60_000,
timer_remaining_ms: null,
timer_ends_at: null,
...overrides,
});
const apply = (base: TimerRow, patch: Partial<TimerRow>): TimerRow => ({ ...base, ...patch });
describe("timerState", () => {
it("is idle at the full duration after a reset", () => {
expect(timerState(row(), NOW)).toEqual({ status: "idle", remainingMs: 60_000, endsAt: null, durationMs: 60_000 });
});
it("counts down from ends_at while running", () => {
const state = timerState(row({ timer_ends_at: at(12_500) }), NOW);
expect(state).toMatchObject({ status: "running", remainingMs: 12_500, endsAt: at(12_500) });
});
it("is finished, never negative, once ends_at has passed", () => {
expect(timerState(row({ timer_ends_at: at(-5_000) }), NOW)).toMatchObject({ status: "finished", remainingMs: 0 });
expect(timerState(row({ timer_ends_at: at(0) }), NOW).status).toBe("finished");
});
it("is paused with what was left, or finished if paused at zero", () => {
expect(timerState(row({ timer_remaining_ms: 7_000 }), NOW)).toMatchObject({ status: "paused", remainingMs: 7_000 });
expect(timerState(row({ timer_remaining_ms: 0 }), NOW).status).toBe("finished");
});
});
describe("start / pause / reset", () => {
it("start runs the full duration and shows the timer", () => {
expect(startPatch(row(), NOW)).toEqual({ timer_ends_at: at(60_000), timer_remaining_ms: null, timer_visible: true });
});
it("pause keeps the time left, and start resumes from it", () => {
const running = apply(row(), startPatch(row(), NOW));
const paused = apply(running, pausePatch(running, NOW + 20_000));
expect(paused).toMatchObject({ timer_ends_at: null, timer_remaining_ms: 40_000 });
const resumed = apply(paused, startPatch(paused, NOW + 90_000));
expect(timerState(resumed, NOW + 90_000)).toMatchObject({ status: "running", remainingMs: 40_000 });
});
it("start and pause do nothing when already in that state", () => {
const running = row({ timer_ends_at: at(30_000) });
expect(startPatch(running, NOW)).toEqual({});
expect(pausePatch(row(), NOW)).toEqual({});
expect(pausePatch(row({ timer_remaining_ms: 5_000 }), NOW)).toEqual({});
expect(pausePatch(row({ timer_ends_at: at(-1) }), NOW)).toEqual({});
});
it("start after time is up begins again from the full duration", () => {
const finished = row({ timer_ends_at: at(-10_000) });
expect(startPatch(finished, NOW)).toMatchObject({ timer_ends_at: at(60_000) });
expect(startPatch(row({ timer_remaining_ms: 0 }), NOW)).toMatchObject({ timer_ends_at: at(60_000) });
});
it("reset and a new duration both return to idle", () => {
const running = row({ timer_ends_at: at(30_000) });
expect(timerState(apply(running, resetPatch()), NOW)).toMatchObject({ status: "idle", remainingMs: 60_000 });
expect(timerState(apply(running, durationPatch(90)), NOW)).toMatchObject({ status: "idle", remainingMs: 90_000 });
});
});
describe("display", () => {
it("rounds seconds up so 0 only shows when time is up", () => {
expect(secondsLeft(10_000)).toBe(10);
expect(secondsLeft(9_001)).toBe(10);
expect(secondsLeft(1)).toBe(1);
expect(secondsLeft(0)).toBe(0);
expect(secondsLeft(-50)).toBe(0);
});
it("formats as m:ss, with hours when needed", () => {
expect(formatClock(65_000)).toBe("1:05");
expect(formatClock(9_200)).toBe("0:10");
expect(formatClock(0)).toBe("0:00");
expect(formatClock(600_000)).toBe("10:00");
expect(formatClock(3_723_000)).toBe("1:02:03");
});
});
+83
View File
@@ -0,0 +1,83 @@
// Countdown timer rules. Pure: the row and the current time go in, state or a DB patch comes out.
// Stored state (see supabase/migrations/0002_timer.sql):
// running : timer_ends_at set paused : timer_remaining_ms set
// reset : both null (= full duration)
export type TimerRow = {
timer_visible: boolean;
timer_duration_ms: number;
timer_remaining_ms: number | null;
timer_ends_at: string | null;
};
export type TimerStatus = "idle" | "running" | "paused" | "finished";
export type TimerState = {
status: TimerStatus;
/** Time left at the moment the state was computed. */
remainingMs: number;
/** Set while running, so each screen can count down by itself between updates. */
endsAt: string | null;
durationMs: number;
};
export type TimerPatch = Partial<Pick<TimerRow, "timer_visible" | "timer_duration_ms" | "timer_remaining_ms" | "timer_ends_at">>;
export const MIN_TIMER_SECONDS = 1;
export const MAX_TIMER_SECONDS = 24 * 60 * 60;
export function timerState(row: TimerRow, nowMs: number): TimerState {
const base = { endsAt: row.timer_ends_at, durationMs: row.timer_duration_ms };
if (row.timer_ends_at) {
const remainingMs = Math.max(0, Date.parse(row.timer_ends_at) - nowMs);
return { ...base, status: remainingMs > 0 ? "running" : "finished", remainingMs };
}
if (row.timer_remaining_ms === null) {
return { ...base, status: "idle", remainingMs: row.timer_duration_ms };
}
return {
...base,
status: row.timer_remaining_ms > 0 ? "paused" : "finished",
remainingMs: row.timer_remaining_ms,
};
}
/** Starts or resumes. A finished timer starts again from the full duration. Also shows it. */
export function startPatch(row: TimerRow, nowMs: number): TimerPatch {
const state = timerState(row, nowMs);
if (state.status === "running") return {};
const remainingMs = state.status === "finished" ? row.timer_duration_ms : state.remainingMs;
return {
timer_ends_at: new Date(nowMs + remainingMs).toISOString(),
timer_remaining_ms: null,
// Starting a timer nobody can see is never what the host means.
timer_visible: true,
};
}
export function pausePatch(row: TimerRow, nowMs: number): TimerPatch {
const state = timerState(row, nowMs);
if (state.status !== "running") return {};
return { timer_ends_at: null, timer_remaining_ms: Math.round(state.remainingMs) };
}
export function resetPatch(): TimerPatch {
return { timer_ends_at: null, timer_remaining_ms: null };
}
/** Changing the duration also resets, so the new time is what shows. */
export function durationPatch(seconds: number): TimerPatch {
return { ...resetPatch(), timer_duration_ms: Math.round(seconds) * 1000 };
}
/** Whole seconds left as shown on screen: 9.2s shows 10, and 0 only shows when time is up. */
export const secondsLeft = (remainingMs: number) => Math.ceil(Math.max(0, remainingMs) / 1000);
/** "1:05", "0:09", or "1:02:03" for an hour or more. */
export function formatClock(remainingMs: number): string {
const total = secondsLeft(remainingMs);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = String(total % 60).padStart(2, "0");
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
}
+14
View File
@@ -0,0 +1,14 @@
-- Game Night Live — countdown timer for the scoreboard.
-- One timer per event, set from the control room. Run after 0001_init.sql.
--
-- State is stored so that any screen can work out the time left on its own:
-- running : timer_ends_at is set (time left = timer_ends_at - now)
-- paused : timer_ends_at is null, timer_remaining_ms holds what was left
-- reset : timer_ends_at is null, timer_remaining_ms is null (= full duration)
alter table events
add column timer_visible boolean not null default false,
add column timer_duration_ms int not null default 60000
check (timer_duration_ms between 1000 and 86400000),
add column timer_remaining_ms int check (timer_remaining_ms >= 0),
add column timer_ends_at timestamptz;