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:
@@ -4,7 +4,11 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireAdmin } from "@/lib/auth";
|
import { requireAdmin } from "@/lib/auth";
|
||||||
|
import type { Json } from "@/lib/database.types";
|
||||||
|
import { loadEventData } from "@/lib/queries/scoring";
|
||||||
import { notify } from "@/lib/realtime";
|
import { notify } from "@/lib/realtime";
|
||||||
|
import { buildFrozenSnapshot } from "@/lib/scoreboard";
|
||||||
|
import { toScoreboardInput } from "@/lib/scoreboard.server";
|
||||||
import { idSchema } from "@/lib/tokens";
|
import { idSchema } from "@/lib/tokens";
|
||||||
|
|
||||||
import type { ActionResult } from "./actions";
|
import type { ActionResult } from "./actions";
|
||||||
@@ -180,3 +184,101 @@ export async function deleteAdjustment(eventId: string, adjustmentId: string): P
|
|||||||
await notify({ eventId });
|
await notify({ eventId });
|
||||||
return ok;
|
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
@@ -32,20 +32,58 @@ body {
|
|||||||
outline-offset: 2px;
|
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 {
|
@keyframes reveal-in {
|
||||||
from {
|
0%,
|
||||||
|
70% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
82% {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reveal-cover {
|
||||||
|
0%,
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
opacity: 0;
|
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 {
|
@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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-reveal-in {
|
.animate-reveal-in {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reveal-cover {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { setCurrentGame, setGameStatus } from "@/app/admin/live-actions";
|
import { setCurrentGame, setGameStatus, setVisibility } from "@/app/admin/live-actions";
|
||||||
import { Button, buttonClass } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatScore } from "@/lib/criteria";
|
import { formatScore } from "@/lib/criteria";
|
||||||
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
|
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
|
||||||
import type { LiveView } from "@/lib/live-view";
|
import type { LiveView } from "@/lib/live-view";
|
||||||
@@ -13,21 +13,22 @@ import { channelNames } from "@/lib/realtime-channels";
|
|||||||
|
|
||||||
import { BonusPanel } from "./bonus-panel";
|
import { BonusPanel } from "./bonus-panel";
|
||||||
import { ErrorText } from "./error-text";
|
import { ErrorText } from "./error-text";
|
||||||
|
import { ScoreboardPanel, VISIBILITY_ORDER } from "./scoreboard-panel";
|
||||||
import { ScoringGrid } from "./scoring-grid";
|
import { ScoringGrid } from "./scoring-grid";
|
||||||
import { useAction } from "./use-action";
|
import { useAction } from "./use-action";
|
||||||
|
|
||||||
const statusIcon = { upcoming: "○", live: "▶", done: "✓" } as const;
|
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 panel = "rounded-lg border border-ink/15 bg-white p-4";
|
||||||
|
|
||||||
const shortcuts = [
|
const shortcuts = [
|
||||||
["N", "Next game"],
|
["N", "Next game"],
|
||||||
["P", "Previous game"],
|
["P", "Previous game"],
|
||||||
|
["H", "Cycle what the room sees: visible → frozen → hidden"],
|
||||||
["?", "Show or hide this help"],
|
["?", "Show or hide this help"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function ControlRoom({ view }: { view: LiveView }) {
|
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 router = useRouter();
|
||||||
const { pending, error, run } = useAction();
|
const { pending, error, run } = useAction();
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
@@ -48,6 +49,13 @@ export function ControlRoom({ view }: { view: LiveView }) {
|
|||||||
[event.id, pending, run],
|
[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(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (keyEvent: KeyboardEvent) => {
|
const onKeyDown = (keyEvent: KeyboardEvent) => {
|
||||||
// Never steal keys from a field the host is typing in.
|
// 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();
|
const key = keyEvent.key.toLowerCase();
|
||||||
if (key === "n") goTo(nextGame?.id);
|
if (key === "n") goTo(nextGame?.id);
|
||||||
else if (key === "p") goTo(previousGame?.id);
|
else if (key === "p") goTo(previousGame?.id);
|
||||||
|
else if (key === "h") cycleVisibility();
|
||||||
else if (key === "?") setShowHelp((shown) => !shown);
|
else if (key === "?") setShowHelp((shown) => !shown);
|
||||||
else if (key === "escape") setShowHelp(false);
|
else if (key === "escape") setShowHelp(false);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("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]));
|
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">
|
<div className="flex flex-col gap-4 lg:col-span-2 xl:col-span-1">
|
||||||
<section className={panel}>
|
<section className={panel}>
|
||||||
<h2 className="text-lg font-bold">Scoreboard</h2>
|
<h2 className="mb-2 text-lg font-bold">Scoreboard</h2>
|
||||||
<p className="mt-1">
|
<ScoreboardPanel view={view} />
|
||||||
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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={panel}>
|
<section className={panel}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,12 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
|
|||||||
const socket = useRefreshChannel(channelNames.scoreboard(token), refetch);
|
const socket = useRefreshChannel(channelNames.scoreboard(token), refetch);
|
||||||
const connection = fetchFailed || socket === "disconnected" ? "offline" : socket === "connected" ? "live" : "connecting";
|
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 rowElements = useRef(new Map<string, HTMLElement>());
|
||||||
const lastTops = useRef(new Map<string, number>());
|
const lastTops = useRef(new Map<string, number>());
|
||||||
|
|
||||||
@@ -126,7 +132,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
|
|||||||
key={team.id}
|
key={team.id}
|
||||||
team={team}
|
team={team}
|
||||||
showScore={!hidden}
|
showScore={!hidden}
|
||||||
justRevealed={revealing}
|
drumRoll={revealing && !revealedOnLoad.has(team.id)}
|
||||||
register={(element) => {
|
register={(element) => {
|
||||||
if (element) rowElements.current.set(team.id, element);
|
if (element) rowElements.current.set(team.id, element);
|
||||||
else rowElements.current.delete(team.id);
|
else rowElements.current.delete(team.id);
|
||||||
@@ -164,12 +170,13 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
|
|||||||
function TeamRow({
|
function TeamRow({
|
||||||
team,
|
team,
|
||||||
showScore,
|
showScore,
|
||||||
justRevealed,
|
drumRoll,
|
||||||
register,
|
register,
|
||||||
}: {
|
}: {
|
||||||
team: ScoreboardTeam;
|
team: ScoreboardTeam;
|
||||||
showScore: boolean;
|
showScore: boolean;
|
||||||
justRevealed: boolean;
|
/** Play the reveal build-up when this row first appears. */
|
||||||
|
drumRoll: boolean;
|
||||||
register: (element: HTMLLIElement | null) => void;
|
register: (element: HTMLLIElement | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const isLeader = showScore && team.rank === 1 && (team.total ?? 0) > 0;
|
const isLeader = showScore && team.rank === 1 && (team.total ?? 0) > 0;
|
||||||
@@ -179,10 +186,19 @@ function TeamRow({
|
|||||||
ref={register}
|
ref={register}
|
||||||
// Container units (cqh/cqw) size the text from the row itself, so rows fill the
|
// Container units (cqh/cqw) size the text from the row itself, so rows fill the
|
||||||
// screen whether there are 3 teams or 12.
|
// 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" : ""
|
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
|
<div
|
||||||
className="flex aspect-square h-full shrink-0 items-center justify-center font-display text-[62cqh] leading-none font-bold tabular-nums"
|
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) }}
|
style={{ backgroundColor: team.color, color: readableTextColor(team.color) }}
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,13 @@
|
|||||||
import type { EventData } from "@/lib/queries/scoring";
|
import type { EventData } from "@/lib/queries/scoring";
|
||||||
import { toScoringInput } 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";
|
import type { Visibility } from "@/lib/scoreboard";
|
||||||
|
|
||||||
// The control room's data, shaped on the server so the client component holds no score math
|
// 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;
|
visibility: Visibility;
|
||||||
currentGameId: string | null;
|
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 }>;
|
teams: Array<{ id: string; name: string; color: string }>;
|
||||||
games: Array<Pick<LiveGame, "id" | "title" | "status">>;
|
games: Array<Pick<LiveGame, "id" | "title" | "status">>;
|
||||||
/** Full detail for the current game only. */
|
/** Full detail for the current game only. */
|
||||||
@@ -55,6 +68,17 @@ export type LiveView = {
|
|||||||
|
|
||||||
const RECENT_ADJUSTMENTS = 12;
|
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 {
|
export function buildLiveView(data: EventData): LiveView {
|
||||||
const result = computeEvent(toScoringInput(data));
|
const result = computeEvent(toScoringInput(data));
|
||||||
const { event } = data;
|
const { event } = data;
|
||||||
@@ -115,6 +139,7 @@ export function buildLiveView(data: EventData): LiveView {
|
|||||||
visibility: event.scoreboard_visibility,
|
visibility: event.scoreboard_visibility,
|
||||||
currentGameId: currentGame?.id ?? null,
|
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 })),
|
teams: data.teams.map((team) => ({ id: team.id, name: team.name, color: team.color })),
|
||||||
games: data.games.map((candidate) => ({
|
games: data.games.map((candidate) => ({
|
||||||
id: candidate.id,
|
id: candidate.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user