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:
+16
-1
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
<section className={panel}>
|
||||
<h2 className="mb-2 text-lg font-bold">Scoreboard — what the room sees</h2>
|
||||
<ScoreboardPanel view={view} />
|
||||
</section>
|
||||
{/* 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}>
|
||||
|
||||
@@ -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's up</span>}
|
||||
{timer.status === "paused" && <span className="text-[5vh] text-white/70">paused</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user