Phase 6: scoreboard visibility toggle and reveal mode

- setVisibility: visible / frozen / hidden. Freezing snapshots the scores
  on the server with the same engine the scoreboard uses; freezing twice
  keeps the original snapshot.
- Reveal mode: start, reveal next (last place first, one click per team),
  finish (sets visible) and cancel. The host sees who is next; the
  scoreboard plays a visual drum-roll for teams revealed while watching.
- H cycles visibility in the control room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 14:58:32 +02:00
co-authored by Claude Opus 5.5
parent f7122b193c
commit da09f59b2a
7 changed files with 358 additions and 27 deletions
+102
View File
@@ -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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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;
}
+42 -4
View File
@@ -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;
}
}
+16 -17
View File
@@ -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 }) {
<div className="flex flex-col gap-4 lg:col-span-2 xl:col-span-1">
<section className={panel}>
<h2 className="text-lg font-bold">Scoreboard</h2>
<p className="mt-1">
Scores are <strong>{visibilityLabel[event.visibility].toLowerCase()}</strong> to the room.
</p>
<a
href={`/s/${event.scoreboardToken}`}
target="_blank"
rel="noreferrer"
className={`${buttonClass("secondary")} mt-2`}
>
Open scoreboard
</a>
<h2 className="mb-2 text-lg font-bold">Scoreboard</h2>
<ScoreboardPanel view={view} />
</section>
<section className={panel}>
+117
View File
@@ -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<Visibility, { action: string; state: string; explain: string }> = {
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 (
<div className="flex flex-col gap-3">
{reveal.active ? (
<div className="flex flex-col gap-2 rounded-md border-2 border-floodlight bg-floodlight/15 p-3">
<p className="font-bold">
Reveal mode · {reveal.revealedCount} of {teams.length} revealed
</p>
{reveal.next ? (
<>
{/* Only the host sees who is next, so they can build it up out loud. */}
<p>
Next: <strong>{ordinal(reveal.next.rank)} place</strong> — {nextTeam?.name} (
{formatScore(reveal.next.total)})
</p>
<Button disabled={pending} onClick={() => run(() => revealNext(event.id))}>
Reveal next
</Button>
</>
) : (
<p>Every team is revealed.</p>
)}
<Button
variant={reveal.next ? "secondary" : "primary"}
disabled={pending}
onClick={() => run(() => finishReveal(event.id))}
>
Finish and show scores
</Button>
<Button variant="secondary" disabled={pending} onClick={() => run(() => cancelReveal(event.id))}>
Cancel reveal
</Button>
</div>
) : (
<>
<div role="group" aria-label="What the room sees" className="grid grid-cols-3 gap-1">
{VISIBILITY_ORDER.map((visibility) => {
const selected = event.visibility === visibility;
return (
<button
key={visibility}
type="button"
aria-pressed={selected}
disabled={pending}
onClick={() => run(() => setVisibility(event.id, visibility))}
className={`min-h-14 rounded-md border px-1 text-sm font-bold disabled:opacity-60 ${
selected
? visibility === "hidden"
? "border-alert bg-alert text-white"
: "border-ink bg-ink text-white"
: "border-ink/25 bg-white hover:bg-ink/5"
}`}
>
{selected ? options[visibility].state : options[visibility].action}
</button>
);
})}
</div>
<p>{options[event.visibility].explain}</p>
</>
)}
<div className="flex flex-wrap gap-2">
<a
href={`/s/${event.scoreboardToken}`}
target="_blank"
rel="noreferrer"
className={buttonClass("secondary")}
>
Open scoreboard
</a>
{!reveal.active && (
<Button
variant="secondary"
disabled={pending || teams.length === 0}
onClick={() => run(() => startReveal(event.id))}
>
Start reveal
</Button>
)}
</div>
<ErrorText>{error}</ErrorText>
</div>
);
}
+21 -5
View File
@@ -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<string, HTMLElement>());
const lastTops = useRef(new Map<string, number>());
@@ -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.
<div
aria-hidden
className="reveal-cover absolute inset-0 z-10 flex items-center justify-center rounded-[1vh] border-2 border-dashed border-floodlight bg-stage font-display text-[60cqh] font-bold text-floodlight"
>
<span className="reveal-shake">?</span>
</div>
)}
<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) }}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { Standing } from "@/lib/scoring";
import { revealState } from "./live-view";
const standing = (teamId: string, rank: number, total: number): Standing => ({
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 });
});
});
+26 -1
View File
@@ -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<Pick<LiveGame, "id" | "title" | "status">>;
/** 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,