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 { 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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 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) }}
|
||||
|
||||
@@ -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 { 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,
|
||||
|
||||
Reference in New Issue
Block a user