diff --git a/src/app/admin/live-actions.ts b/src/app/admin/live-actions.ts index c53e7cc..39abee5 100644 --- a/src/app/admin/live-actions.ts +++ b/src/app/admin/live-actions.ts @@ -4,7 +4,11 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { requireAdmin } from "@/lib/auth"; +import type { Json } from "@/lib/database.types"; +import { loadEventData } from "@/lib/queries/scoring"; import { notify } from "@/lib/realtime"; +import { buildFrozenSnapshot } from "@/lib/scoreboard"; +import { toScoreboardInput } from "@/lib/scoreboard.server"; import { idSchema } from "@/lib/tokens"; import type { ActionResult } from "./actions"; @@ -180,3 +184,101 @@ export async function deleteAdjustment(eventId: string, adjustmentId: string): P await notify({ eventId }); return ok; } + +// ---------- scoreboard visibility + reveal ---------- + +export async function setVisibility( + eventId: string, + visibility: "visible" | "frozen" | "hidden", +): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId) || !["visible", "frozen", "hidden"].includes(visibility)) return BAD_ID; + + const { data: event, error: loadError } = await supabase + .from("events") + .select("*") + .eq("id", eventId) + .maybeSingle(); + if (loadError) return dbFail("Changing visibility", loadError.message); + if (!event) return BAD_ID; + + let snapshot: Json | null = null; + if (visibility === "frozen") { + // Freezing twice must not move the frozen scores forward. + if (event.scoreboard_visibility === "frozen" && event.frozen_snapshot) return ok; + // The snapshot is taken here on the server, from the same engine the scoreboard uses, + // so "frozen" shows exactly what was on screen a moment ago. + snapshot = buildFrozenSnapshot(toScoreboardInput(await loadEventData(supabase, event))); + } + + const { error } = await supabase + .from("events") + .update({ scoreboard_visibility: visibility, frozen_snapshot: snapshot }) + .eq("id", eventId); + if (error) return dbFail("Changing visibility", error.message); + + revalidatePath(livePath(eventId)); + await notify({ eventId }); + return ok; +} + +/** Starts reveal mode: the scoreboard shows every team as a mystery row. */ +export async function startReveal(eventId: string): Promise { + return updateReveal(eventId, "Starting the reveal", () => ({ reveal_active: true, reveal_count: 0 })); +} + +/** Reveals one more team, last place first. Tied teams still take one click each. */ +export async function revealNext(eventId: string): Promise { + return updateReveal(eventId, "Revealing", (event, teamCount) => + event.reveal_active ? { reveal_count: Math.min(event.reveal_count + 1, teamCount) } : null, + ); +} + +/** Ends reveal mode and leaves the full scoreboard visible. */ +export async function finishReveal(eventId: string): Promise { + return updateReveal(eventId, "Finishing the reveal", () => ({ + reveal_active: false, + reveal_count: 0, + scoreboard_visibility: "visible" as const, + frozen_snapshot: null, + })); +} + +/** Leaves reveal mode without changing visibility (for a reveal started by mistake). */ +export async function cancelReveal(eventId: string): Promise { + return updateReveal(eventId, "Cancelling the reveal", () => ({ reveal_active: false, reveal_count: 0 })); +} + +type RevealPatch = { + reveal_active?: boolean; + reveal_count?: number; + scoreboard_visibility?: "visible"; + frozen_snapshot?: null; +}; + +async function updateReveal( + eventId: string, + what: string, + patchFor: (event: { reveal_active: boolean; reveal_count: number }, teamCount: number) => RevealPatch | null, +): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId)) return BAD_ID; + + const [event, teams] = await Promise.all([ + supabase.from("events").select("reveal_active, reveal_count").eq("id", eventId).maybeSingle(), + supabase.from("teams").select("id", { count: "exact", head: true }).eq("event_id", eventId), + ]); + const loadError = event.error ?? teams.error; + if (loadError) return dbFail(what, loadError.message); + if (!event.data) return BAD_ID; + + const patch = patchFor(event.data, teams.count ?? 0); + if (!patch) return fail("Reveal mode isn't running. Start the reveal first."); + + const { error } = await supabase.from("events").update(patch).eq("id", eventId); + if (error) return dbFail(what, error.message); + + revalidatePath(livePath(eventId)); + await notify({ eventId }); + return ok; +} diff --git a/src/app/globals.css b/src/app/globals.css index 8192e49..5c60321 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -32,20 +32,58 @@ body { outline-offset: 2px; } -/* Scoreboard: a newly revealed team pops in. */ +/* Scoreboard reveal: a shaking "?" covers the row for a beat (the drum-roll), then the + cover fades and the team pops in. */ @keyframes reveal-in { - from { + 0%, + 70% { + transform: scale(1); + } + 82% { + transform: scale(1.04); + } +} + +@keyframes reveal-cover { + 0%, + 70% { + opacity: 1; + } + 100% { opacity: 0; - transform: scale(0.92); + visibility: hidden; + } +} + +@keyframes reveal-shake { + 0%, + 100% { + transform: translateX(-0.5vh) rotate(-4deg); + } + 50% { + transform: translateX(0.5vh) rotate(4deg); } } @theme inline { - --animate-reveal-in: reveal-in 700ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + --animate-reveal-in: reveal-in 1800ms ease-out both; +} + +.reveal-cover { + animation: reveal-cover 1800ms ease-out both; +} + +.reveal-shake { + display: inline-block; + animation: reveal-shake 140ms ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { .animate-reveal-in { animation: none; } + + .reveal-cover { + display: none; + } } diff --git a/src/components/admin/control-room.tsx b/src/components/admin/control-room.tsx index 3865776..f4034cf 100644 --- a/src/components/admin/control-room.tsx +++ b/src/components/admin/control-room.tsx @@ -4,8 +4,8 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; -import { setCurrentGame, setGameStatus } from "@/app/admin/live-actions"; -import { Button, buttonClass } from "@/components/ui/button"; +import { setCurrentGame, setGameStatus, setVisibility } from "@/app/admin/live-actions"; +import { Button } from "@/components/ui/button"; import { formatScore } from "@/lib/criteria"; import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel"; import type { LiveView } from "@/lib/live-view"; @@ -13,21 +13,22 @@ import { channelNames } from "@/lib/realtime-channels"; import { BonusPanel } from "./bonus-panel"; import { ErrorText } from "./error-text"; +import { ScoreboardPanel, VISIBILITY_ORDER } from "./scoreboard-panel"; import { ScoringGrid } from "./scoring-grid"; import { useAction } from "./use-action"; const statusIcon = { upcoming: "○", live: "▶", done: "✓" } as const; -const visibilityLabel = { visible: "Visible", frozen: "Frozen", hidden: "Hidden" } as const; const panel = "rounded-lg border border-ink/15 bg-white p-4"; const shortcuts = [ ["N", "Next game"], ["P", "Previous game"], + ["H", "Cycle what the room sees: visible → frozen → hidden"], ["?", "Show or hide this help"], ] as const; export function ControlRoom({ view }: { view: LiveView }) { - const { event, teams, games, currentGame, standings } = view; + const { event, teams, games, currentGame, standings, reveal } = view; const router = useRouter(); const { pending, error, run } = useAction(); const [showHelp, setShowHelp] = useState(false); @@ -48,6 +49,13 @@ export function ControlRoom({ view }: { view: LiveView }) { [event.id, pending, run], ); + const cycleVisibility = useCallback(() => { + // During a reveal the toggle is replaced by the reveal controls, so H does nothing. + if (pending || reveal.active) return; + const next = VISIBILITY_ORDER[(VISIBILITY_ORDER.indexOf(event.visibility) + 1) % VISIBILITY_ORDER.length]; + run(() => setVisibility(event.id, next)); + }, [event.id, event.visibility, pending, reveal.active, run]); + useEffect(() => { const onKeyDown = (keyEvent: KeyboardEvent) => { // Never steal keys from a field the host is typing in. @@ -58,12 +66,13 @@ export function ControlRoom({ view }: { view: LiveView }) { const key = keyEvent.key.toLowerCase(); if (key === "n") goTo(nextGame?.id); else if (key === "p") goTo(previousGame?.id); + else if (key === "h") cycleVisibility(); else if (key === "?") setShowHelp((shown) => !shown); else if (key === "escape") setShowHelp(false); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [goTo, nextGame?.id, previousGame?.id]); + }, [cycleVisibility, goTo, nextGame?.id, previousGame?.id]); const teamsById = new Map(teams.map((team) => [team.id, team])); @@ -191,18 +200,8 @@ export function ControlRoom({ view }: { view: LiveView }) {
-

Scoreboard

-

- Scores are {visibilityLabel[event.visibility].toLowerCase()} to the room. -

- - Open scoreboard - +

Scoreboard

+
diff --git a/src/components/admin/scoreboard-panel.tsx b/src/components/admin/scoreboard-panel.tsx new file mode 100644 index 0000000..ba9bcec --- /dev/null +++ b/src/components/admin/scoreboard-panel.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { cancelReveal, finishReveal, revealNext, setVisibility, startReveal } from "@/app/admin/live-actions"; +import { Button, buttonClass } from "@/components/ui/button"; +import { formatScore } from "@/lib/criteria"; +import type { LiveView } from "@/lib/live-view"; +import type { Visibility } from "@/lib/scoreboard"; + +import { ErrorText } from "./error-text"; +import { useAction } from "./use-action"; + +export const VISIBILITY_ORDER: Visibility[] = ["visible", "frozen", "hidden"]; + +const options: Record = { + visible: { action: "Show scores", state: "Visible", explain: "The room sees live scores." }, + frozen: { + action: "Freeze scores", + state: "Frozen", + explain: "The room sees the scores from the moment you froze them.", + }, + hidden: { action: "Hide scores", state: "Hidden", explain: "The room sees team names only, no scores." }, +}; + +const ordinal = (rank: number) => { + const suffix = rank % 100 >= 11 && rank % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][rank % 10] ?? "th"); + return `${rank}${suffix}`; +}; + +export function ScoreboardPanel({ view }: { view: LiveView }) { + const { event, reveal, teams } = view; + const { pending, error, run } = useAction(); + const nextTeam = reveal.next ? teams.find((team) => team.id === reveal.next?.teamId) : undefined; + + return ( +
+ {reveal.active ? ( +
+

+ Reveal mode · {reveal.revealedCount} of {teams.length} revealed +

+ {reveal.next ? ( + <> + {/* Only the host sees who is next, so they can build it up out loud. */} +

+ Next: {ordinal(reveal.next.rank)} place — {nextTeam?.name} ( + {formatScore(reveal.next.total)}) +

+ + + ) : ( +

Every team is revealed.

+ )} + + +
+ ) : ( + <> +
+ {VISIBILITY_ORDER.map((visibility) => { + const selected = event.visibility === visibility; + return ( + + ); + })} +
+

{options[event.visibility].explain}

+ + )} + +
+ + Open scoreboard + + {!reveal.active && ( + + )} +
+ {error} +
+ ); +} diff --git a/src/components/scoreboard/scoreboard.tsx b/src/components/scoreboard/scoreboard.tsx index d53e485..25ecdb4 100644 --- a/src/components/scoreboard/scoreboard.tsx +++ b/src/components/scoreboard/scoreboard.tsx @@ -36,6 +36,12 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo const socket = useRefreshChannel(channelNames.scoreboard(token), refetch); const connection = fetchFailed || socket === "disconnected" ? "offline" : socket === "connected" ? "live" : "connecting"; + // Teams already revealed when this page loaded appear at once; only teams revealed + // while watching get the drum-roll. + const [revealedOnLoad] = useState( + () => new Set(initial.teams.filter((team) => team.rank !== undefined).map((team) => team.id)), + ); + const rowElements = useRef(new Map()); const lastTops = useRef(new Map()); @@ -126,7 +132,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo key={team.id} team={team} showScore={!hidden} - justRevealed={revealing} + drumRoll={revealing && !revealedOnLoad.has(team.id)} register={(element) => { if (element) rowElements.current.set(team.id, element); else rowElements.current.delete(team.id); @@ -164,12 +170,13 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo function TeamRow({ team, showScore, - justRevealed, + drumRoll, register, }: { team: ScoreboardTeam; showScore: boolean; - justRevealed: boolean; + /** Play the reveal build-up when this row first appears. */ + drumRoll: boolean; register: (element: HTMLLIElement | null) => void; }) { const isLeader = showScore && team.rank === 1 && (team.total ?? 0) > 0; @@ -179,10 +186,19 @@ function TeamRow({ 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] ${ + className={`relative 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" : ""}`} + } ${drumRoll ? "animate-reveal-in" : ""}`} > + {drumRoll && ( + // Visual drum-roll: a shaking "?" covers the row for a beat before the team appears. +
+ ? +
+ )}
({ + teamId, + rank, + total, + gamePoints: total, + adjustmentPoints: 0, + perGame: [], +}); +const standings = [standing("a", 1, 30), standing("b", 2, 20), standing("c", 2, 20), standing("d", 4, 5)]; + +describe("revealState", () => { + it("reveals from last place up, one team per step, ties included", () => { + expect(revealState(true, 0, standings).next).toEqual({ teamId: "d", rank: 4, total: 5 }); + expect(revealState(true, 1, standings).next?.teamId).toBe("c"); + expect(revealState(true, 2, standings).next?.teamId).toBe("b"); + expect(revealState(true, 3, standings).next?.teamId).toBe("a"); + }); + + it("has nothing left once everyone is revealed, and clamps bad counts", () => { + expect(revealState(true, 4, standings)).toEqual({ active: true, revealedCount: 4, next: null }); + expect(revealState(true, 99, standings).revealedCount).toBe(4); + expect(revealState(true, -1, standings).next?.teamId).toBe("d"); + }); + + it("is inert when reveal mode is off", () => { + expect(revealState(false, 2, standings)).toEqual({ active: false, revealedCount: 0, next: null }); + }); +}); diff --git a/src/lib/live-view.ts b/src/lib/live-view.ts index 6ca4285..ac7c8d3 100644 --- a/src/lib/live-view.ts +++ b/src/lib/live-view.ts @@ -1,6 +1,13 @@ import type { EventData } from "@/lib/queries/scoring"; import { toScoringInput } from "@/lib/queries/scoring"; -import { computeEvent, type CellResult, type GameStatus, type PointsMode, type ScoringMode } from "@/lib/scoring"; +import { + computeEvent, + type CellResult, + type GameStatus, + type PointsMode, + type ScoringMode, + type Standing, +} from "@/lib/scoring"; import type { Visibility } from "@/lib/scoreboard"; // The control room's data, shaped on the server so the client component holds no score math @@ -44,6 +51,12 @@ export type LiveView = { visibility: Visibility; currentGameId: string | null; }; + reveal: { + active: boolean; + revealedCount: number; + /** The team the next "Reveal next" click will show, or null when all are revealed. */ + next: { teamId: string; rank: number; total: number } | null; + }; teams: Array<{ id: string; name: string; color: string }>; games: Array>; /** Full detail for the current game only. */ @@ -55,6 +68,17 @@ export type LiveView = { const RECENT_ADJUSTMENTS = 12; +/** Reveal runs last place → first, so the next team is counted from the end of the standings. */ +export function revealState(active: boolean, count: number, standings: Standing[]): LiveView["reveal"] { + const revealedCount = Math.min(Math.max(count, 0), standings.length); + const next = active ? standings[standings.length - 1 - revealedCount] : undefined; + return { + active, + revealedCount: active ? revealedCount : 0, + next: next ? { teamId: next.teamId, rank: next.rank, total: next.total } : null, + }; +} + export function buildLiveView(data: EventData): LiveView { const result = computeEvent(toScoringInput(data)); const { event } = data; @@ -115,6 +139,7 @@ export function buildLiveView(data: EventData): LiveView { visibility: event.scoreboard_visibility, currentGameId: currentGame?.id ?? null, }, + reveal: revealState(event.reveal_active, event.reveal_count, result.standings), teams: data.teams.map((team) => ({ id: team.id, name: team.name, color: team.color })), games: data.games.map((candidate) => ({ id: candidate.id,