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:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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't valid. Ask the host for a new one.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
return <Scoreboard token={token} initial={payload} />;
|
||||
}
|
||||
@@ -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))}</>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user