Phase 4: scoreboard page, public API and realtime refresh

- buildScoreboardPayload is the single place deciding what the public may
  see: visible, frozen (snapshot scores, live current game), hidden (no
  scores, alphabetical) and reveal (unrevealed teams carry no score/order).
- GET /api/public/scoreboard/[token] validates the token, then loads the
  event with the service client in one parallel round trip.
- notify() pings sb/admin/game channels over Realtime's REST endpoint with
  no data; useRefreshChannel refetches immediately on a ping, throttles
  bursts, and falls back to a 10s poll, focus and reconnect.
- /s/[token]: FLIP rank animation, count-up totals, reduced motion,
  fullscreen, cursor hide and a connection indicator.
- Admin setup actions now notify, so renames and reorders show up live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 14:19:03 +02:00
co-authored by Claude Opus 5.5
parent b60c9db4f4
commit 17eb76d036
18 changed files with 977 additions and 8 deletions
+4 -3
View File
@@ -40,7 +40,7 @@ Channels (names use tokens/ids; payload is always just `{ type: "refresh", scope
`src/lib/realtime.ts` exposes `notify({ eventId, gameId? })` which looks up the scoreboard token and sends to the right channels from the server.
Use Supabase's server-side broadcast (REST endpoint / `httpSend` — check current docs).
Debounce refetches client-side (~150ms) so a burst of taps causes one fetch.
Throttle refetches client-side: the first ping refetches immediately, further pings within ~150ms collapse into one trailing refetch, so a burst of taps causes at most two fetches.
Clients also poll every 10s as a safety net, and refetch on reconnect / tab focus.
## Scoring engine (`src/lib/scoring/`)
@@ -57,12 +57,13 @@ type ScoreboardPayload = {
id: string; name: string; color: string;
// omitted entirely when hidden:
rank?: number; total?: number;
perGame?: Array<{ gameId: string; points: number }>;
// v1 sends totals only; no per-game points.
}>;
updatedAt: string;
};
```
During reveal mode, teams not yet revealed are sent without rank/total.
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.
## Folder layout
```
+4 -4
View File
@@ -66,15 +66,15 @@ This is the screen used during the night. Must be fast and hard to mess up.
### 4.3 Scoreboard (`/s/[token]`)
- Projector-first, 16:9, readable from the back of a hall.
- Shows: event name, current game title, team standings (rank, name, colour, total points),
optionally per-game points columns for completed games.
- Shows: event name, current game title, team standings (rank, name, colour, total points).
v1 shows totals only; per-game points columns are a later option.
- Updates live (< 1s) when admin changes anything. Rank changes animate.
- **Visible** — live totals.
- **Frozen** — shows totals as they were the moment it was frozen, with a subtle "scores frozen" note.
Server stores the snapshot; live scores are not sent.
Server stores the snapshot; live scores are not sent. The current game title still updates live.
- **Hidden** — no scores sent at all. Shows team names (no order implied — alphabetical) and the current game, with a "scores are hidden" state.
- **Reveal mode** (admin-triggered): scoreboard reveals final standings last place → first,
one team per admin click ("Reveal next"), then sets visibility to Visible.
one team per admin click ("Reveal next") — tied teams too, each showing the shared rank — then sets visibility to Visible.
- Auto-reconnects; also polls every 10s as a fallback. Shows a small offline dot if disconnected.
- Fullscreen button; hides cursor after inactivity.
+11
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth";
import { criteriaPresets, type PresetKey } from "@/lib/presets";
import { notify } from "@/lib/realtime";
import { idSchema } from "@/lib/tokens";
import {
eventSchema,
@@ -76,6 +77,7 @@ export async function updateEvent(
revalidatePath("/admin");
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -154,6 +156,7 @@ export async function addTeam(eventId: string, name: string): Promise<ActionResu
if (error) return dbFail("Adding the team", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -175,6 +178,7 @@ export async function updateTeam(
if (error) return dbFail("Saving the team", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -186,6 +190,7 @@ export async function deleteTeam(eventId: string, teamId: string): Promise<Actio
if (error) return dbFail("Deleting the team", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -217,6 +222,7 @@ export async function moveTeam(
if (failed?.error) return dbFail("Reordering", failed.error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -319,6 +325,7 @@ export async function addGame(eventId: string, presetKey: PresetKey): Promise<Ac
if (error) return dbFail("Adding the criteria", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -382,6 +389,7 @@ export async function saveGame(eventId: string, gameId: string, input: GameDraft
if (failed?.error) return dbFail("Saving the criteria", failed.error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -403,6 +411,7 @@ export async function duplicateGame(eventId: string, gameId: string): Promise<Ac
if (error) return dbFail("Copying the game", error);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -414,6 +423,7 @@ export async function deleteGame(eventId: string, gameId: string): Promise<Actio
if (error) return dbFail("Deleting the game", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
@@ -444,5 +454,6 @@ export async function moveGame(
if (failed?.error) return dbFail("Reordering", failed.error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
return ok;
}
+12 -1
View File
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { EventDetailsForm } from "@/components/admin/event-details-form";
import { GamesEditor } from "@/components/admin/games-editor";
import { TeamsEditor } from "@/components/admin/teams-editor";
import { buttonClass } from "@/components/ui/button";
import { requireAdmin } from "@/lib/auth";
import { loadEventSetup } from "@/lib/queries/event";
import { idSchema } from "@/lib/tokens";
@@ -23,7 +24,17 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
<Link href="/admin" className="text-sm underline underline-offset-4">
← All game nights
</Link>
<h1 className="mt-1 font-display text-4xl font-bold">{event.name}</h1>
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
<h1 className="font-display text-4xl font-bold">{event.name}</h1>
<a
href={`/s/${event.scoreboard_token}`}
target="_blank"
rel="noreferrer"
className={buttonClass("secondary")}
>
Open scoreboard
</a>
</div>
</div>
<section className="rounded-lg border border-ink/15 bg-white p-4">
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { getScoreboardPayload } from "@/lib/scoreboard.server";
// Scores change by the second and depend on visibility, so nothing may cache this.
const headers = { "Cache-Control": "no-store" };
export async function GET(_request: Request, context: RouteContext<"/api/public/scoreboard/[token]">) {
const { token } = await context.params;
try {
const payload = await getScoreboardPayload(token);
if (!payload) {
return NextResponse.json({ error: "Scoreboard not found." }, { status: 404, headers });
}
return NextResponse.json(payload, { headers });
} catch (error) {
console.error("scoreboard API failed", error);
return NextResponse.json({ error: "Could not load the scoreboard." }, { status: 500, headers });
}
}
+18
View File
@@ -31,3 +31,21 @@ body {
outline: 3px solid var(--ink);
outline-offset: 2px;
}
/* Scoreboard: a newly revealed team pops in. */
@keyframes reveal-in {
from {
opacity: 0;
transform: scale(0.92);
}
}
@theme inline {
--animate-reveal-in: reveal-in 700ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.animate-reveal-in {
animation: none;
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Scoreboard } from "@/components/scoreboard/scoreboard";
import { getScoreboardPayload } from "@/lib/scoreboard.server";
export const metadata: Metadata = {
title: "Scoreboard",
// The link is the only secret; keep it out of search engines.
robots: { index: false, follow: false },
};
export default async function ScoreboardPage(props: PageProps<"/s/[token]">) {
const { token } = await props.params;
// Rendered on the server first so the projector never shows an empty flash.
const payload = await getScoreboardPayload(token);
if (!payload) {
return (
<main className="flex min-h-dvh items-center justify-center bg-stage p-6 text-center text-white">
<p className="font-display text-4xl font-bold">
This scoreboard link isn&apos;t valid. Ask the host for a new one.
</p>
</main>
);
}
return <Scoreboard token={token} initial={payload} />;
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect, useRef, useState } from "react";
/** Decimals needed to show `value` exactly, capped at 2 (totals are rounded to 2dp). */
const decimalsOf = (value: number) => {
const [, fraction = ""] = value.toFixed(2).replace(/0+$/, "").split(".");
return fraction.length;
};
/** Shows a number, counting up (or down) to each new value. Instant with reduced motion. */
export function CountUp({ value, durationMs }: { value: number; durationMs: number }) {
const [shown, setShown] = useState(value);
// Where the display currently is, so an update mid-count continues from there.
const shownRef = useRef(value);
useEffect(() => {
const from = shownRef.current;
if (from === value) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const start = performance.now();
let frame = requestAnimationFrame(function tick(now) {
const progress = reduced ? 1 : Math.min(1, (now - start) / durationMs);
const eased = 1 - (1 - progress) ** 3;
const next = progress === 1 ? value : from + (value - from) * eased;
shownRef.current = next;
setShown(next);
if (progress < 1) frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [value, durationMs]);
// Count in whole numbers when the target is whole, so digits don't flicker with decimals.
return <>{shown.toFixed(decimalsOf(value))}</>;
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { readableTextColor } from "@/lib/color";
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
import { channelNames } from "@/lib/realtime-channels";
import type { ScoreboardPayload, ScoreboardTeam } from "@/lib/scoreboard";
import { CountUp } from "./count-up";
const MOVE_MS = 600;
const IDLE_MS = 3000;
const connectionDot = { live: "bg-signal", connecting: "bg-white/40", offline: "bg-alert" };
const connectionLabel = { live: "live", connecting: "connecting…", offline: "offline — reconnecting" };
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
export function Scoreboard({ token, initial }: { token: string; initial: ScoreboardPayload }) {
const [payload, setPayload] = useState(initial);
const [fetchFailed, setFetchFailed] = useState(false);
const refetch = useCallback(async () => {
try {
const response = await fetch(`/api/public/scoreboard/${token}`, { cache: "no-store" });
if (!response.ok) throw new Error(String(response.status));
setPayload((await response.json()) as ScoreboardPayload);
setFetchFailed(false);
} catch {
// Keep showing the last good scores; the dot tells the host something is wrong.
setFetchFailed(true);
}
}, [token]);
const socket = useRefreshChannel(channelNames.scoreboard(token), refetch);
const connection = fetchFailed || socket === "disconnected" ? "offline" : socket === "connected" ? "live" : "connecting";
const rowElements = useRef(new Map<string, HTMLElement>());
const lastTops = useRef(new Map<string, number>());
// FLIP: rows are re-ordered in the DOM, then slid from where they were to where they are.
useLayoutEffect(() => {
const animate = !prefersReducedMotion();
rowElements.current.forEach((element, id) => {
// offsetTop ignores transforms, so a move that interrupts another still measures correctly.
const top = element.offsetTop;
const previous = lastTops.current.get(id);
if (animate && previous !== undefined && previous !== top) {
element.animate(
[{ transform: `translateY(${previous - top}px)` }, { transform: "translateY(0)" }],
{ duration: MOVE_MS, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" },
);
// The one bold moment: a team that climbs gets a floodlight flash.
if (top < previous) {
element.animate(
[
{ boxShadow: "0 0 0 0 var(--floodlight)" },
{ boxShadow: "0 0 0 0.6vh var(--floodlight)", offset: 0.35 },
{ boxShadow: "0 0 0 0 transparent" },
],
{ duration: MOVE_MS * 1.5, easing: "ease-out" },
);
}
}
lastTops.current.set(id, top);
});
for (const id of lastTops.current.keys()) {
if (!rowElements.current.has(id)) lastTops.current.delete(id);
}
}, [payload]);
// A resize moves every row; re-measure so the next update doesn't animate from stale positions.
useEffect(() => {
const remeasure = () =>
rowElements.current.forEach((element, id) => lastTops.current.set(id, element.offsetTop));
window.addEventListener("resize", remeasure);
return () => window.removeEventListener("resize", remeasure);
}, []);
const idle = useIdle(IDLE_MS);
const { isFullscreen, toggleFullscreen } = useFullscreen();
const revealing = payload.reveal?.active ?? false;
const hidden = payload.visibility === "hidden" && !revealing;
const { currentGame } = payload;
return (
<div
className={`flex h-dvh flex-col gap-[2.5vh] overflow-hidden bg-stage px-[4vw] py-[3.5vh] text-white ${idle ? "cursor-none" : ""}`}
>
<header className="flex items-baseline justify-between gap-[3vw] font-display text-[clamp(1.25rem,5vh,4.5rem)] leading-none font-bold">
<h1 className="truncate">{payload.eventName}</h1>
{currentGame && (
<p className="shrink-0 text-floodlight">
<span className="text-white/70">
Game {currentGame.index} of {currentGame.total}:
</span>{" "}
{currentGame.title}
</p>
)}
</header>
{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
</p>
)}
<ol className="flex min-h-0 flex-1 flex-col justify-center gap-[1.2vh]">
{payload.teams.length === 0 && (
<li className="self-center text-[clamp(1rem,3.2vh,2.5rem)] text-white/70">No teams yet</li>
)}
{payload.teams.map((team, index) =>
revealing && team.rank === undefined ? (
// Keyed by slot, not team: a mystery row must say nothing about who is in it.
<li
key={`slot-${index}`}
aria-label="Not revealed yet"
className="flex max-h-[20vh] min-h-0 flex-1 basis-0 items-center justify-center rounded-[1vh] border-2 border-dashed border-white/25 font-display text-[4vh] text-white/40"
>
?
</li>
) : (
<TeamRow
key={team.id}
team={team}
showScore={!hidden}
justRevealed={revealing}
register={(element) => {
if (element) rowElements.current.set(team.id, element);
else rowElements.current.delete(team.id);
}}
/>
),
)}
</ol>
<footer
className={`flex items-center justify-end gap-[2vw] text-[clamp(0.875rem,2.2vh,1.5rem)] transition-opacity duration-500 ${idle ? "opacity-40" : ""}`}
>
{payload.visibility === "frozen" && !revealing && (
<span className="mr-auto text-white/70">Scores frozen</span>
)}
<span role="status" className="flex items-center gap-[0.6em]">
<span
aria-hidden
className={`inline-block size-[0.7em] rounded-full ${connectionDot[connection]}`}
/>
{connectionLabel[connection]}
</span>
<button
type="button"
onClick={toggleFullscreen}
className={`min-h-11 rounded-md border border-white/30 px-4 font-bold hover:bg-white/10 focus-visible:outline-floodlight ${idle ? "invisible" : ""}`}
>
{isFullscreen ? "Exit fullscreen" : "Fullscreen"}
</button>
</footer>
</div>
);
}
function TeamRow({
team,
showScore,
justRevealed,
register,
}: {
team: ScoreboardTeam;
showScore: boolean;
justRevealed: boolean;
register: (element: HTMLLIElement | null) => void;
}) {
const isLeader = showScore && team.rank === 1 && (team.total ?? 0) > 0;
return (
<li
ref={register}
// Container units (cqh/cqw) size the text from the row itself, so rows fill the
// screen whether there are 3 teams or 12.
className={`flex max-h-[20vh] min-h-0 flex-1 basis-0 items-stretch overflow-hidden rounded-[1vh] bg-white/10 [container-type:size] ${
isLeader ? "outline-[0.5vh] outline-floodlight outline-solid" : ""
} ${justRevealed ? "animate-reveal-in" : ""}`}
>
<div
className="flex aspect-square h-full shrink-0 items-center justify-center font-display text-[62cqh] leading-none font-bold tabular-nums"
style={{ backgroundColor: team.color, color: readableTextColor(team.color) }}
>
{showScore && team.rank !== undefined ? team.rank : ""}
</div>
<div className="flex min-w-0 flex-1 items-center px-[3cqw] font-display text-[min(58cqh,7cqw)] leading-none font-bold">
<span className="truncate">{team.name}</span>
</div>
{showScore && (
<div className="flex shrink-0 items-center pr-[3cqw] font-display text-[min(72cqh,9cqw)] leading-none font-bold tabular-nums">
{team.total !== undefined ? <CountUp value={team.total} durationMs={MOVE_MS} /> : "–"}
</div>
)}
</li>
);
}
/** True after `ms` without mouse or keyboard activity (hides the cursor on the projector). */
function useIdle(ms: number) {
const [idle, setIdle] = useState(false);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const wake = () => {
setIdle(false);
clearTimeout(timer);
timer = setTimeout(() => setIdle(true), ms);
};
const events = ["mousemove", "mousedown", "keydown", "touchstart"] as const;
events.forEach((name) => window.addEventListener(name, wake));
timer = setTimeout(() => setIdle(true), ms);
return () => {
clearTimeout(timer);
events.forEach((name) => window.removeEventListener(name, wake));
};
}, [ms]);
return idle;
}
function useFullscreen() {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onChange = () => setIsFullscreen(document.fullscreenElement !== null);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
const toggleFullscreen = useCallback(() => {
// Rejections (e.g. the browser blocking fullscreen) are harmless; the button just does nothing.
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else void document.documentElement.requestFullscreen().catch(() => {});
}, []);
return { isFullscreen, toggleFullscreen };
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { readableTextColor } from "./color";
describe("readableTextColor", () => {
it("uses white on dark colours and ink on light ones", () => {
expect(readableTextColor("#1B1640")).toBe("#FFFFFF");
expect(readableTextColor("#000000")).toBe("#FFFFFF");
expect(readableTextColor("#FFD23F")).toBe("#14213D");
expect(readableTextColor("#ffffff")).toBe("#14213D");
expect(readableTextColor("#2EC4B6")).toBe("#14213D");
});
it("falls back to ink for anything that isn't #RRGGBB", () => {
expect(readableTextColor("red")).toBe("#14213D");
});
});
+20
View File
@@ -0,0 +1,20 @@
const INK = "#14213D";
const WHITE = "#FFFFFF";
/** WCAG relative luminance of a #RRGGBB colour. */
function luminance(hex: string): number {
const channels = [1, 3, 5].map((start) => {
const value = parseInt(hex.slice(start, start + 2), 16) / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
/** White or Ink, whichever reads better on a team's colour (docs/UI.md). */
export function readableTextColor(background: string): string {
if (!/^#[0-9A-Fa-f]{6}$/.test(background)) return INK;
const bg = luminance(background);
return contrast(bg, luminance(WHITE)) >= contrast(bg, luminance(INK)) ? WHITE : INK;
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { REFRESH_EVENT } from "@/lib/realtime-channels";
import { createClient } from "@/lib/supabase/browser";
const MIN_GAP_MS = 150;
const POLL_MS = 10_000;
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
/**
* Calls `refetch` whenever the server pings `channelName`, plus safety nets:
* on (re)connect, on tab focus, when the network returns, and every 10s.
* Returns the realtime socket's state; "connecting" only until the first result.
*/
export function useRefreshChannel(channelName: string | null, refetch: () => void): ConnectionStatus {
const [status, setStatus] = useState<ConnectionStatus>("connecting");
// Keep the latest refetch without re-subscribing when the caller passes a new function.
const refetchRef = useRef(refetch);
useEffect(() => {
refetchRef.current = refetch;
}, [refetch]);
useEffect(() => {
if (!channelName) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let lastRun = 0;
// The first ping refetches immediately (no added latency); a burst of taps
// right after it collapses into one trailing refetch instead of one per tap.
const scheduleRefetch = () => {
clearTimeout(timer);
const wait = lastRun + MIN_GAP_MS - Date.now();
const run = () => {
lastRun = Date.now();
refetchRef.current();
};
if (wait <= 0) run();
else timer = setTimeout(run, wait);
};
const supabase = createClient();
const channel = supabase
.channel(channelName)
.on("broadcast", { event: REFRESH_EVENT }, scheduleRefetch)
.subscribe((state) => {
setStatus(state === "SUBSCRIBED" ? "connected" : "disconnected");
// Anything sent while we were disconnected was missed, so catch up.
if (state === "SUBSCRIBED") scheduleRefetch();
});
const poll = setInterval(scheduleRefetch, POLL_MS);
const onVisible = () => {
if (document.visibilityState === "visible") scheduleRefetch();
};
window.addEventListener("focus", scheduleRefetch);
window.addEventListener("online", scheduleRefetch);
document.addEventListener("visibilitychange", onVisible);
return () => {
clearTimeout(timer);
clearInterval(poll);
window.removeEventListener("focus", scheduleRefetch);
window.removeEventListener("online", scheduleRefetch);
document.removeEventListener("visibilitychange", onVisible);
void supabase.removeChannel(channel);
};
}, [channelName]);
return status;
}
+109
View File
@@ -0,0 +1,109 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database, Tables } from "@/lib/database.types";
import type { ScoringInput } from "@/lib/scoring";
type Client = SupabaseClient<Database>;
export type EventData = {
event: Tables<"events">;
teams: Tables<"teams">[];
games: Tables<"games">[];
criteria: Tables<"criteria">[];
judges: Tables<"judges">[];
scores: Tables<"scores">[];
adjustments: Tables<"adjustments">[];
};
const PAGE_SIZE = 1000;
// `games!inner()` joins through games purely to filter by event, so child rows load in
// the same round trip as the games themselves instead of waiting for their ids.
const VIA_GAME = "*, games!inner()";
/** Supabase returns at most 1000 rows per request; a busy night has more scores than that. */
async function loadAllScores(supabase: Client, eventId: string): Promise<Tables<"scores">[]> {
const rows: Tables<"scores">[] = [];
for (let from = 0; ; from += PAGE_SIZE) {
const { data, error } = await supabase
.from("scores")
.select(VIA_GAME)
.eq("games.event_id", eventId)
.order("id")
.range(from, from + PAGE_SIZE - 1)
.overrideTypes<Tables<"scores">[], { merge: false }>();
if (error) throw new Error(`Could not load scores: ${error.message}`);
rows.push(...data);
if (data.length < PAGE_SIZE) return rows;
}
}
/**
* Everything the scoring engine needs for one event, in one parallel round trip.
* Shared by the admin pages (RLS client) and the public/judge APIs (service client,
* after the token has been validated).
*/
export async function loadEventData(supabase: Client, event: Tables<"events">): Promise<EventData> {
const [teams, games, adjustments, criteria, judges, scores] = await Promise.all([
supabase.from("teams").select("*").eq("event_id", event.id).order("sort_order").order("created_at"),
supabase.from("games").select("*").eq("event_id", event.id).order("sort_order").order("created_at"),
supabase.from("adjustments").select("*").eq("event_id", event.id).order("created_at"),
supabase
.from("criteria")
.select(VIA_GAME)
.eq("games.event_id", event.id)
.order("sort_order")
.overrideTypes<Tables<"criteria">[], { merge: false }>(),
supabase
.from("judges")
.select(VIA_GAME)
.eq("games.event_id", event.id)
.order("created_at")
.overrideTypes<Tables<"judges">[], { merge: false }>(),
loadAllScores(supabase, event.id),
]);
const error = teams.error ?? games.error ?? adjustments.error ?? criteria.error ?? judges.error;
if (error) throw new Error(`Could not load the event: ${error.message}`);
return {
event,
teams: teams.data ?? [],
games: games.data ?? [],
adjustments: adjustments.data ?? [],
criteria: criteria.data ?? [],
judges: judges.data ?? [],
scores,
};
}
/** Maps DB rows to the scoring engine's plain input. */
export function toScoringInput(data: EventData): ScoringInput {
return {
teams: data.teams.map((team) => ({ id: team.id, sortOrder: team.sort_order })),
games: data.games.map((game) => ({
id: game.id,
scoringMode: game.scoring_mode,
pointsMode: game.points_mode,
multiplier: game.multiplier,
placementPoints: game.placement_points,
status: game.status,
})),
criteria: data.criteria.map((criterion) => ({
id: criterion.id,
gameId: criterion.game_id,
weight: criterion.weight,
})),
judges: data.judges.map((judge) => ({ id: judge.id, gameId: judge.game_id, revoked: judge.revoked })),
scores: data.scores.map((score) => ({
gameId: score.game_id,
teamId: score.team_id,
criterionId: score.criterion_id,
judgeId: score.judge_id,
value: score.value,
})),
adjustments: data.adjustments.map((adjustment) => ({
teamId: adjustment.team_id,
points: adjustment.points,
})),
};
}
+8
View File
@@ -0,0 +1,8 @@
// Shared by the server (notify) and the browser (useRefreshChannel). See docs/ARCHITECTURE.md.
export const REFRESH_EVENT = "refresh";
export const channelNames = {
scoreboard: (scoreboardToken: string) => `sb:${scoreboardToken}`,
game: (gameId: string) => `game:${gameId}`,
admin: (eventId: string) => `admin:${eventId}`,
};
+54
View File
@@ -0,0 +1,54 @@
import "server-only";
import { createAdminClient } from "@/lib/supabase/admin";
import { channelNames, REFRESH_EVENT } from "./realtime-channels";
type NotifyArgs = { eventId: string; gameId?: string };
// A scoreboard token never changes, so remember it and save a DB round trip per ping.
const scoreboardTokens = new Map<string, string>();
/**
* Pings every screen that shows this event so it refetches from the API.
* The message carries no data (CLAUDE.md rule 4): hidden/frozen rules live only in the API.
* Never throws: the mutation already succeeded, and clients poll every 10s as a fallback.
*/
export async function notify({ eventId, gameId }: NotifyArgs): Promise<void> {
try {
const supabase = createAdminClient();
let scoreboardToken = scoreboardTokens.get(eventId);
if (!scoreboardToken) {
const { data: event } = await supabase
.from("events")
.select("scoreboard_token")
.eq("id", eventId)
.maybeSingle();
scoreboardToken = event?.scoreboard_token;
if (scoreboardToken) scoreboardTokens.set(eventId, scoreboardToken);
}
const topics = [channelNames.admin(eventId)];
if (scoreboardToken) topics.push(channelNames.scoreboard(scoreboardToken));
if (gameId) topics.push(channelNames.game(gameId));
await Promise.all(
topics.map(async (topic) => {
const channel = supabase.channel(topic);
try {
// httpSend uses Realtime's REST endpoint, so the server never opens a websocket.
const result = await channel.httpSend(
REFRESH_EVENT,
{ type: "refresh", scope: gameId ? "game" : "event" },
{ timeout: 3000 },
);
if (!result.success) console.error(`notify: ${topic} failed`, result);
} finally {
await supabase.removeChannel(channel);
}
}),
);
} catch (error) {
console.error("notify failed", error);
}
}
+46
View File
@@ -0,0 +1,46 @@
import "server-only";
import type { Tables } from "@/lib/database.types";
import { loadEventData, toScoringInput, type EventData } from "@/lib/queries/scoring";
import { computeEvent } from "@/lib/scoring";
import { buildScoreboardPayload, type ScoreboardInput, type ScoreboardPayload } from "@/lib/scoreboard";
import { createAdminClient } from "@/lib/supabase/admin";
import { scoreboardTokenSchema } from "@/lib/tokens";
export function toScoreboardInput(data: EventData): ScoreboardInput {
const { event } = data;
return {
event: {
name: event.name,
visibility: event.scoreboard_visibility,
frozenSnapshot: event.frozen_snapshot,
currentGameId: event.current_game_id,
revealActive: event.reveal_active,
revealCount: event.reveal_count,
},
teams: data.teams,
games: data.games,
standings: computeEvent(toScoringInput(data)).standings,
now: new Date().toISOString(),
};
}
/**
* Token in, sanitised payload out. Returns null for a malformed or unknown token.
* Uses the service-role client, so the token check here is the only gate (CLAUDE.md rule 2).
*/
export async function getScoreboardPayload(token: string): Promise<ScoreboardPayload | null> {
const parsed = scoreboardTokenSchema.safeParse(token);
if (!parsed.success) return null;
const supabase = createAdminClient();
const { data: event, error } = await supabase
.from("events")
.select("*")
.eq("scoreboard_token", parsed.data)
.maybeSingle<Tables<"events">>();
if (error) throw new Error(`Could not load the scoreboard: ${error.message}`);
if (!event) return null;
return buildScoreboardPayload(toScoreboardInput(await loadEventData(supabase, event)));
}
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import type { Standing } from "@/lib/scoring";
import { buildFrozenSnapshot, buildScoreboardPayload, type ScoreboardInput } from "./scoreboard";
const standing = (teamId: string, rank: number, total: number): Standing => ({
teamId,
rank,
total,
gamePoints: total,
adjustmentPoints: 0,
perGame: [],
});
// Standings order (best first): Wolves 30, Lions 20, Eagles 20, Sharks 5.
const input = (event: Partial<ScoreboardInput["event"]> = {}): ScoreboardInput => ({
event: {
name: "Worship Game Night",
visibility: "visible",
frozenSnapshot: null,
currentGameId: "g2",
revealActive: false,
revealCount: 0,
...event,
},
teams: [
{ id: "lions", name: "Lions", color: "#E63946" },
{ id: "eagles", name: "eagles", color: "#3A86FF" },
{ id: "sharks", name: "Sharks", color: "#2EC4B6" },
{ id: "wolves", name: "Wolves", color: "#FFB703" },
],
games: [
{ id: "g1", title: "Name that tune" },
{ id: "g2", title: "Karaoke" },
{ id: "g3", title: "Charades" },
],
standings: [
standing("wolves", 1, 30),
standing("lions", 2, 20),
standing("eagles", 2, 20),
standing("sharks", 4, 5),
],
now: "2026-10-03T12:00:00.000Z",
});
/** No score of any kind may appear anywhere in the serialised payload for these teams. */
const hasNoScores = (teams: unknown[]) => !/"(rank|total|perGame)"/.test(JSON.stringify(teams));
describe("visible", () => {
it("sends teams in standings order with rank and total", () => {
const payload = buildScoreboardPayload(input());
expect(payload.teams).toEqual([
{ id: "wolves", name: "Wolves", color: "#FFB703", rank: 1, total: 30 },
{ id: "lions", name: "Lions", color: "#E63946", rank: 2, total: 20 },
{ id: "eagles", name: "eagles", color: "#3A86FF", rank: 2, total: 20 },
{ id: "sharks", name: "Sharks", color: "#2EC4B6", rank: 4, total: 5 },
]);
expect(payload.reveal).toBeNull();
expect(payload.currentGame).toEqual({ id: "g2", title: "Karaoke", index: 2, total: 3 });
expect(payload.updatedAt).toBe("2026-10-03T12:00:00.000Z");
});
it("has no current game when none is set or it was deleted", () => {
expect(buildScoreboardPayload(input({ currentGameId: null })).currentGame).toBeNull();
expect(buildScoreboardPayload(input({ currentGameId: "gone" })).currentGame).toBeNull();
});
});
describe("hidden", () => {
it("sends no scores at all, alphabetically, but keeps the current game", () => {
const payload = buildScoreboardPayload(input({ visibility: "hidden" }));
expect(payload.teams.map((team) => team.name)).toEqual(["eagles", "Lions", "Sharks", "Wolves"]);
expect(hasNoScores(payload.teams)).toBe(true);
expect(JSON.stringify(payload)).not.toMatch(/\b(30|20)\b/);
expect(payload.currentGame?.title).toBe("Karaoke");
});
});
describe("frozen", () => {
const snapshot = {
teams: [
{ id: "lions", rank: 1, total: 12 },
{ id: "deleted-team", rank: 2, total: 9 },
{ id: "wolves", rank: 3, total: 4 },
],
};
it("sends the snapshot's scores, not the live ones, with live names and current game", () => {
const payload = buildScoreboardPayload(input({ visibility: "frozen", frozenSnapshot: snapshot }));
expect(payload.teams).toEqual([
{ id: "lions", name: "Lions", color: "#E63946", rank: 1, total: 12 },
{ id: "wolves", name: "Wolves", color: "#FFB703", rank: 3, total: 4 },
// Added after the freeze: listed, but with no score.
{ id: "eagles", name: "eagles", color: "#3A86FF" },
{ id: "sharks", name: "Sharks", color: "#2EC4B6" },
]);
expect(JSON.stringify(payload)).not.toMatch(/\b30\b/);
expect(payload.currentGame?.title).toBe("Karaoke");
});
it.each([null, "junk", { teams: [{ id: "lions" }] }])(
"falls back to hidden, never to live scores, when the snapshot is %j",
(frozenSnapshot) => {
const payload = buildScoreboardPayload(input({ visibility: "frozen", frozenSnapshot }));
expect(hasNoScores(payload.teams)).toBe(true);
expect(payload.teams).toHaveLength(4);
},
);
it("round-trips through buildFrozenSnapshot", () => {
const frozenSnapshot = buildFrozenSnapshot(input());
expect(hasNoScores([frozenSnapshot])).toBe(false);
const payload = buildScoreboardPayload(input({ visibility: "frozen", frozenSnapshot }));
expect(payload.teams).toEqual(buildScoreboardPayload(input()).teams);
});
});
describe("reveal", () => {
it("reveals last place first; unrevealed teams carry no score and no order", () => {
const payload = buildScoreboardPayload(input({ revealActive: true, revealCount: 1, visibility: "hidden" }));
expect(payload.reveal).toEqual({ active: true, revealedCount: 1 });
expect(payload.teams).toEqual([
{ id: "eagles", name: "eagles", color: "#3A86FF" },
{ id: "lions", name: "Lions", color: "#E63946" },
{ id: "wolves", name: "Wolves", color: "#FFB703" },
{ id: "sharks", name: "Sharks", color: "#2EC4B6", rank: 4, total: 5 },
]);
});
it("reveals tied teams one click each", () => {
const payload = buildScoreboardPayload(input({ revealActive: true, revealCount: 2 }));
expect(payload.teams.filter((team) => team.rank !== undefined).map((team) => team.id)).toEqual([
"eagles",
"sharks",
]);
});
it("starts with nothing revealed and clamps out-of-range counts", () => {
const none = buildScoreboardPayload(input({ revealActive: true, revealCount: 0 }));
expect(hasNoScores(none.teams)).toBe(true);
const negative = buildScoreboardPayload(input({ revealActive: true, revealCount: -3 }));
expect(hasNoScores(negative.teams)).toBe(true);
expect(negative.reveal?.revealedCount).toBe(0);
const all = buildScoreboardPayload(input({ revealActive: true, revealCount: 99 }));
expect(all.teams.map((team) => team.id)).toEqual(["wolves", "lions", "eagles", "sharks"]);
expect(all.reveal?.revealedCount).toBe(4);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { z } from "zod";
import type { Standing } from "@/lib/scoring";
// 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.
export type Visibility = "visible" | "frozen" | "hidden";
export type ScoreboardTeam = {
id: string;
name: string;
color: string;
/** Both omitted entirely when the team's score must not be shown. */
rank?: number;
total?: number;
};
export type ScoreboardPayload = {
eventName: string;
visibility: Visibility;
currentGame: { id: string; title: string; index: number; total: number } | null;
reveal: { active: boolean; revealedCount: number } | null;
teams: ScoreboardTeam[];
updatedAt: string;
};
export type ScoreboardInput = {
event: {
name: string;
visibility: Visibility;
frozenSnapshot: unknown;
currentGameId: string | null;
revealActive: boolean;
revealCount: number;
};
teams: Array<{ id: string; name: string; color: string }>;
/** In running order. */
games: Array<{ id: string; title: string }>;
/** From the scoring engine, already sorted for display. */
standings: Standing[];
now: string;
};
const snapshotSchema = z.object({
teams: z.array(z.object({ id: z.string(), rank: z.number(), total: z.number() })),
});
export type FrozenSnapshot = z.infer<typeof snapshotSchema>;
const byName = (a: { name: string }, b: { name: string }) =>
a.name.localeCompare(b.name, "en", { sensitivity: "base" });
const withoutScore = ({ id, name, color }: ScoreboardTeam): ScoreboardTeam => ({ id, name, color });
function scoredTeams(input: ScoreboardInput): ScoreboardTeam[] {
const teamsById = new Map(input.teams.map((team) => [team.id, team]));
return input.standings.flatMap((standing) => {
const team = teamsById.get(standing.teamId);
return team ? [{ id: team.id, name: team.name, color: team.color, rank: standing.rank, total: standing.total }] : [];
});
}
/** What gets stored in events.frozen_snapshot at the moment of freezing. */
export function buildFrozenSnapshot(input: ScoreboardInput): FrozenSnapshot {
return {
teams: scoredTeams(input).map((team) => ({ id: team.id, rank: team.rank!, total: team.total! })),
};
}
function teamsFor(input: ScoreboardInput): ScoreboardTeam[] {
const { event } = input;
// Alphabetical so the order implies nothing about the standings.
const anonymous = () => input.teams.map(withoutScore).sort(byName);
if (event.revealActive) {
// Standings are best-first; the reveal runs last place → first, so the
// revealed teams are the tail. Unrevealed teams go first, alphabetically.
const live = scoredTeams(input);
const revealed = live.slice(live.length - Math.min(Math.max(event.revealCount, 0), live.length));
const revealedIds = new Set(revealed.map((team) => team.id));
return [...anonymous().filter((team) => !revealedIds.has(team.id)), ...revealed];
}
if (event.visibility === "hidden") return anonymous();
if (event.visibility === "frozen") {
const snapshot = snapshotSchema.safeParse(event.frozenSnapshot);
// A missing or corrupt snapshot must never fall back to live scores.
if (!snapshot.success) return anonymous();
// Scores come from the snapshot; names and colours stay live so a typo fix shows up.
const teamsById = new Map(input.teams.map((team) => [team.id, team]));
const frozen = snapshot.data.teams.flatMap((entry) => {
const team = teamsById.get(entry.id);
return team ? [{ id: team.id, name: team.name, color: team.color, rank: entry.rank, total: entry.total }] : [];
});
const frozenIds = new Set(frozen.map((team) => team.id));
return [...frozen, ...anonymous().filter((team) => !frozenIds.has(team.id))];
}
return scoredTeams(input);
}
export function buildScoreboardPayload(input: ScoreboardInput): ScoreboardPayload {
const { event, games } = input;
const currentIndex = games.findIndex((game) => game.id === event.currentGameId);
return {
eventName: event.name,
visibility: event.visibility,
// The current game is always live, even while scores are frozen or hidden.
currentGame:
currentIndex >= 0
? {
id: games[currentIndex].id,
title: games[currentIndex].title,
index: currentIndex + 1,
total: games.length,
}
: null,
reveal: event.revealActive
? { active: true, revealedCount: Math.min(Math.max(event.revealCount, 0), input.teams.length) }
: null,
teams: teamsFor(input),
updatedAt: input.now,
};
}