diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b1adf33..83dccc8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 ``` diff --git a/docs/PRD.md b/docs/PRD.md index c27d324..7ac0dba 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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. diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index ccfda19..288713f 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -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 ← All game nights -

{event.name}

+
+

{event.name}

+ + Open scoreboard + +
diff --git a/src/app/api/public/scoreboard/[token]/route.ts b/src/app/api/public/scoreboard/[token]/route.ts new file mode 100644 index 0000000..408e768 --- /dev/null +++ b/src/app/api/public/scoreboard/[token]/route.ts @@ -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 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 26cda71..8192e49 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/s/[token]/page.tsx b/src/app/s/[token]/page.tsx new file mode 100644 index 0000000..2a620d1 --- /dev/null +++ b/src/app/s/[token]/page.tsx @@ -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 ( +
+

+ This scoreboard link isn't valid. Ask the host for a new one. +

+
+ ); + } + return ; +} diff --git a/src/components/scoreboard/count-up.tsx b/src/components/scoreboard/count-up.tsx new file mode 100644 index 0000000..609ace6 --- /dev/null +++ b/src/components/scoreboard/count-up.tsx @@ -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))}; +} diff --git a/src/components/scoreboard/scoreboard.tsx b/src/components/scoreboard/scoreboard.tsx new file mode 100644 index 0000000..d53e485 --- /dev/null +++ b/src/components/scoreboard/scoreboard.tsx @@ -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()); + const lastTops = useRef(new Map()); + + // 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 ( +
+
+

{payload.eventName}

+ {currentGame && ( +

+ + Game {currentGame.index} of {currentGame.total}: + {" "} + {currentGame.title} +

+ )} +
+ + {hidden && ( +

+ Scores are hidden — final reveal soon +

+ )} + +
    + {payload.teams.length === 0 && ( +
  1. No teams yet
  2. + )} + {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. +
  3. + ? +
  4. + ) : ( + { + if (element) rowElements.current.set(team.id, element); + else rowElements.current.delete(team.id); + }} + /> + ), + )} +
+ +
+ {payload.visibility === "frozen" && !revealing && ( + Scores frozen + )} + + + {connectionLabel[connection]} + + +
+
+ ); +} + +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 ( +
  • +
    + {showScore && team.rank !== undefined ? team.rank : ""} +
    +
    + {team.name} +
    + {showScore && ( +
    + {team.total !== undefined ? : "–"} +
    + )} +
  • + ); +} + +/** 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; + 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 }; +} diff --git a/src/lib/color.test.ts b/src/lib/color.test.ts new file mode 100644 index 0000000..d8cc1d5 --- /dev/null +++ b/src/lib/color.test.ts @@ -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"); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..e611b27 --- /dev/null +++ b/src/lib/color.ts @@ -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; +} diff --git a/src/lib/hooks/use-refresh-channel.ts b/src/lib/hooks/use-refresh-channel.ts new file mode 100644 index 0000000..c0c2ec5 --- /dev/null +++ b/src/lib/hooks/use-refresh-channel.ts @@ -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("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 | 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; +} diff --git a/src/lib/queries/scoring.ts b/src/lib/queries/scoring.ts new file mode 100644 index 0000000..1da6a1a --- /dev/null +++ b/src/lib/queries/scoring.ts @@ -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; + +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[]> { + 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[], { 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 { + 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[], { merge: false }>(), + supabase + .from("judges") + .select(VIA_GAME) + .eq("games.event_id", event.id) + .order("created_at") + .overrideTypes[], { 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, + })), + }; +} diff --git a/src/lib/realtime-channels.ts b/src/lib/realtime-channels.ts new file mode 100644 index 0000000..68529c3 --- /dev/null +++ b/src/lib/realtime-channels.ts @@ -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}`, +}; diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts new file mode 100644 index 0000000..f22d541 --- /dev/null +++ b/src/lib/realtime.ts @@ -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(); + +/** + * 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 { + 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); + } +} diff --git a/src/lib/scoreboard.server.ts b/src/lib/scoreboard.server.ts new file mode 100644 index 0000000..f5508d8 --- /dev/null +++ b/src/lib/scoreboard.server.ts @@ -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 { + 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>(); + if (error) throw new Error(`Could not load the scoreboard: ${error.message}`); + if (!event) return null; + + return buildScoreboardPayload(toScoreboardInput(await loadEventData(supabase, event))); +} diff --git a/src/lib/scoreboard.test.ts b/src/lib/scoreboard.test.ts new file mode 100644 index 0000000..894dcaf --- /dev/null +++ b/src/lib/scoreboard.test.ts @@ -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: { + 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); + }); +}); diff --git a/src/lib/scoreboard.ts b/src/lib/scoreboard.ts new file mode 100644 index 0000000..53f0495 --- /dev/null +++ b/src/lib/scoreboard.ts @@ -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; + +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, + }; +}