Phase 5: control room with direct scoring, bonuses and shortcuts

- /admin/events/[eventId]/live: game list with status, previous/next,
  mark done/reopen, scoring grid, standings, and bonus/penalty with undo.
- Scores autosave per cell with optimistic UI; the validate_score trigger
  and RLS do the checking so a save costs one DB round trip.
- BlockInput and NumberStepper are shared UI, ready for the judge page.
- buildLiveView shapes all data on the server from the scoring engine, so
  the client components contain no score math.
- N / P switch games, ? shows shortcut help.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 14:48:41 +02:00
co-authored by Claude Opus 5.5
parent 17eb76d036
commit f7122b193c
12 changed files with 1091 additions and 4 deletions
@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { ControlRoom } from "@/components/admin/control-room";
import { requireAdmin } from "@/lib/auth";
import { buildLiveView } from "@/lib/live-view";
import { loadEventData } from "@/lib/queries/scoring";
import { idSchema } from "@/lib/tokens";
export default async function ControlRoomPage(props: PageProps<"/admin/events/[eventId]/live">) {
const { supabase } = await requireAdmin();
const { eventId } = await props.params;
if (!idSchema.safeParse(eventId).success) notFound();
const { data: event, error } = await supabase.from("events").select("*").eq("id", eventId).maybeSingle();
if (error) throw new Error(`Could not load the event: ${error.message}`);
if (!event) notFound();
return <ControlRoom view={buildLiveView(await loadEventData(supabase, event))} />;
}
+4 -1
View File
@@ -19,13 +19,16 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
const { event, teams, games } = setup;
return (
<div className="flex flex-col gap-8">
<div className="mx-auto flex max-w-5xl flex-col gap-8">
<div>
<Link href="/admin" className="text-sm underline underline-offset-4">
← All game nights
</Link>
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
<h1 className="font-display text-4xl font-bold">{event.name}</h1>
<Link href={`/admin/events/${event.id}/live`} className={`${buttonClass("primary")} ml-auto`}>
Control room
</Link>
<a
href={`/s/${event.scoreboard_token}`}
target="_blank"
+3 -2
View File
@@ -11,7 +11,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
return (
<div className="min-h-dvh">
<header className="border-b border-ink/15 bg-white">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-2">
<div className="mx-auto flex max-w-[100rem] flex-wrap items-center justify-between gap-2 px-4 py-2">
<Link href="/admin" className="font-display text-2xl font-bold">
{publicEnv.NEXT_PUBLIC_APP_NAME}
</Link>
@@ -23,7 +23,8 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
</form>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
{/* Pages set their own width: setup is narrow, the control room is wide. */}
<main className="px-4 py-6">{children}</main>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireAdmin } from "@/lib/auth";
import { notify } from "@/lib/realtime";
import { idSchema } from "@/lib/tokens";
import type { ActionResult } from "./actions";
// Server actions for the control room (/admin/events/[eventId]/live).
const ok: ActionResult = { ok: true };
const fail = (error: string): ActionResult => ({ ok: false, error });
const dbFail = (what: string, message: string) => fail(`${what} failed: ${message}. Try again.`);
const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success);
const BAD_ID = fail("That item no longer exists. Reload the page.");
const livePath = (eventId: string) => `/admin/events/${eventId}/live`;
// ---------- games ----------
/** Makes a game the current one. An upcoming game becomes live, so it starts counting. */
export async function setCurrentGame(eventId: string, gameId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return BAD_ID;
const [game, event] = await Promise.all([
supabase
.from("games")
.update({ status: "live" })
.eq("id", gameId)
.eq("event_id", eventId)
.eq("status", "upcoming"),
supabase.from("events").update({ current_game_id: gameId }).eq("id", eventId).select("id"),
]);
const error = game.error ?? event.error;
if (error) return dbFail("Switching game", error.message);
if (!event.data?.length) return BAD_ID;
revalidatePath(livePath(eventId));
await notify({ eventId, gameId });
return ok;
}
export async function setGameStatus(
eventId: string,
gameId: string,
status: "live" | "done",
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId) || !["live", "done"].includes(status)) return BAD_ID;
const { error } = await supabase
.from("games")
.update({ status })
.eq("id", gameId)
.eq("event_id", eventId);
if (error) return dbFail("Updating the game", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId, gameId });
return ok;
}
// ---------- scores ----------
const scoreSchema = z.object({
value: z.number().min(-999999).max(999999).nullable(),
});
/** Turns the validate_score trigger's messages into something a host can act on. */
function scoreError(message: string): ActionResult {
if (message.includes("out of range")) return fail("That score is outside this criterion's range.");
if (message.includes("not on step")) return fail("That score doesn't fit this criterion's step.");
if (message.includes("does not belong") || message.includes("foreign key")) return BAD_ID;
return dbFail("Saving the score", message);
}
/**
* Saves the admin's value for one cell (the score in a direct game, an override in a judged
* game); null clears it. Range, step and criterion↔game are enforced by the validate_score
* trigger, and ownership by RLS, so this skips a validation round trip to keep autosave fast.
*/
export async function saveAdminScore(
eventId: string,
gameId: string,
teamId: string,
criterionId: string,
value: number | null,
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, teamId, criterionId)) return BAD_ID;
const parsed = scoreSchema.safeParse({ value });
if (!parsed.success) return fail("That score isn't a valid number.");
if (parsed.data.value === null) {
const { error } = await supabase
.from("scores")
.delete()
.eq("game_id", gameId)
.eq("team_id", teamId)
.eq("criterion_id", criterionId)
.is("judge_id", null);
if (error) return scoreError(error.message);
} else {
const { error } = await supabase.from("scores").upsert(
{
game_id: gameId,
team_id: teamId,
criterion_id: criterionId,
judge_id: null,
value: parsed.data.value,
},
{ onConflict: "team_id,criterion_id,judge_id" },
);
if (error) return scoreError(error.message);
}
// No revalidatePath: the control room refreshes itself, so the "saved" tick isn't
// held up by a full page re-render.
await notify({ eventId, gameId });
return ok;
}
// ---------- bonus / penalty ----------
const adjustmentSchema = z.object({
points: z
.number("Enter an amount.")
.min(-9999, "That's too many points.")
.max(9999, "That's too many points.")
.refine((points) => points !== 0, "The amount can't be 0."),
reason: z.string().trim().max(200, "The reason can be at most 200 characters."),
});
export async function addAdjustment(
eventId: string,
teamId: string,
input: { points: number; reason: string; gameId: string | null },
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, teamId) || (input.gameId !== null && !validIds(input.gameId))) return BAD_ID;
const parsed = adjustmentSchema.safeParse(input);
if (!parsed.success) return fail(parsed.error.issues[0].message);
// The team must be in this event; RLS alone would allow a team from another of your events.
const team = await supabase.from("teams").select("id").eq("id", teamId).eq("event_id", eventId).maybeSingle();
if (team.error) return dbFail("Adding points", team.error.message);
if (!team.data) return BAD_ID;
const { error } = await supabase.from("adjustments").insert({
event_id: eventId,
team_id: teamId,
game_id: input.gameId,
points: parsed.data.points,
reason: parsed.data.reason || null,
});
if (error) return dbFail("Adding points", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId });
return ok;
}
/** Undo for a bonus or penalty. */
export async function deleteAdjustment(eventId: string, adjustmentId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, adjustmentId)) return BAD_ID;
const { error } = await supabase
.from("adjustments")
.delete()
.eq("id", adjustmentId)
.eq("event_id", eventId);
if (error) return dbFail("Undoing", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId });
return ok;
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default async function AdminHome() {
if (error) throw new Error(`Could not load events: ${error.message}`);
return (
<div className="flex flex-col gap-8">
<div className="mx-auto flex max-w-5xl flex-col gap-8">
<section>
<h1 className="font-display text-4xl font-bold">Your game nights</h1>
{events.length === 0 ? (
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { addAdjustment, deleteAdjustment } from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
import { inputClass } from "@/components/ui/field";
import { formatScore } from "@/lib/criteria";
import type { LiveView } from "@/lib/live-view";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
const QUICK_AMOUNTS = [1, 5, 10, -5];
const signed = (points: number) => (points > 0 ? `+${formatScore(points)}` : `−${formatScore(-points)}`);
export function BonusPanel({ view }: { view: LiveView }) {
const { event, teams, adjustments } = view;
const { pending, error, run } = useAction();
const [teamId, setTeamId] = useState(teams[0]?.id ?? "");
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const teamName = (id: string) => teams.find((team) => team.id === id)?.name ?? "Deleted team";
// Tied to the current game for context; it counts toward the total either way.
const add = (id: string, points: number, why: string, onSuccess?: () => void) =>
run(() => addAdjustment(event.id, id, { points, reason: why, gameId: event.currentGameId }), onSuccess);
if (teams.length === 0) return <p>Add teams on the setup page to give bonus points.</p>;
return (
<div className="flex flex-col gap-4">
<ul className="flex flex-col gap-2">
{teams.map((team) => (
<li key={team.id} className="flex flex-wrap items-center gap-1">
<span className="min-w-0 flex-1 basis-full truncate font-bold sm:basis-auto">
<span
aria-hidden
className="mr-2 inline-block size-3 rounded-full"
style={{ backgroundColor: team.color }}
/>
{team.name}
</span>
{QUICK_AMOUNTS.map((points) => (
<Button
key={points}
variant={points < 0 ? "danger" : "secondary"}
size="sm"
disabled={pending}
aria-label={`${signed(points)} for ${team.name}`}
onClick={() => add(team.id, points, "")}
>
{signed(points)}
</Button>
))}
</li>
))}
</ul>
<form
className="flex flex-col gap-2 border-t border-ink/15 pt-3"
onSubmit={(submitEvent) => {
submitEvent.preventDefault();
add(teamId, amount === "" ? Number.NaN : Number(amount), reason, () => {
setAmount("");
setReason("");
});
}}
>
<span className="text-sm font-bold">Custom amount</span>
<div className="grid grid-cols-[minmax(0,1fr)_6rem] gap-2">
<select
aria-label="Team"
value={teamId}
onChange={(changeEvent) => setTeamId(changeEvent.target.value)}
className={inputClass}
>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
<input
type="number"
step="any"
aria-label="Points (negative for a penalty)"
placeholder="±pts"
value={amount}
onChange={(changeEvent) => setAmount(changeEvent.target.value)}
className={inputClass}
/>
</div>
<input
aria-label="Reason (optional)"
placeholder="Reason (optional)"
maxLength={200}
value={reason}
onChange={(changeEvent) => setReason(changeEvent.target.value)}
className={inputClass}
/>
<Button type="submit" variant="secondary" disabled={pending}>
Add bonus or penalty
</Button>
</form>
<ErrorText>{error}</ErrorText>
{adjustments.length > 0 && (
<div className="border-t border-ink/15 pt-3">
<h3 className="text-sm font-bold">Recent</h3>
<ul className="mt-1 flex flex-col gap-1">
{adjustments.map((adjustment) => (
<li key={adjustment.id} className="flex items-center gap-2">
<span className="min-w-0 flex-1">
<span className={`font-bold tabular-nums ${adjustment.points < 0 ? "text-alert" : ""}`}>
{signed(adjustment.points)}
</span>{" "}
{teamName(adjustment.teamId)}
{adjustment.reason && <span className="text-ink/70"> — {adjustment.reason}</span>}
</span>
<Button
variant="secondary"
size="sm"
disabled={pending}
aria-label={`Undo ${signed(adjustment.points)} for ${teamName(adjustment.teamId)}`}
onClick={() => run(() => deleteAdjustment(event.id, adjustment.id))}
>
Undo
</Button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
"use client";
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 { formatScore } from "@/lib/criteria";
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
import type { LiveView } from "@/lib/live-view";
import { channelNames } from "@/lib/realtime-channels";
import { BonusPanel } from "./bonus-panel";
import { ErrorText } from "./error-text";
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"],
["?", "Show or hide this help"],
] as const;
export function ControlRoom({ view }: { view: LiveView }) {
const { event, teams, games, currentGame, standings } = view;
const router = useRouter();
const { pending, error, run } = useAction();
const [showHelp, setShowHelp] = useState(false);
// Judges' scores and edits from other tabs arrive as pings on the admin channel.
const refresh = useCallback(() => router.refresh(), [router]);
const socket = useRefreshChannel(channelNames.admin(event.id), refresh);
const currentIndex = games.findIndex((game) => game.id === event.currentGameId);
// With no current game yet, "Next" starts the first one.
const previousGame = currentIndex > 0 ? games[currentIndex - 1] : undefined;
const nextGame = games[currentIndex + 1];
const goTo = useCallback(
(gameId: string | undefined) => {
if (gameId && !pending) run(() => setCurrentGame(event.id, gameId));
},
[event.id, pending, run],
);
useEffect(() => {
const onKeyDown = (keyEvent: KeyboardEvent) => {
// Never steal keys from a field the host is typing in.
const target = keyEvent.target as HTMLElement;
if (target.closest("input, textarea, select, [contenteditable]")) return;
if (keyEvent.ctrlKey || keyEvent.metaKey || keyEvent.altKey) return;
const key = keyEvent.key.toLowerCase();
if (key === "n") goTo(nextGame?.id);
else if (key === "p") goTo(previousGame?.id);
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]);
const teamsById = new Map(teams.map((team) => [team.id, team]));
return (
<div className="mx-auto flex max-w-[100rem] flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<Link href={`/admin/events/${event.id}`} className="text-sm underline underline-offset-4">
← Setup
</Link>
<h1 className="font-display text-4xl font-bold">{event.name} · control room</h1>
</div>
<div className="flex items-center gap-3">
<span role="status" className="flex items-center gap-2 text-sm">
<span
aria-hidden
className={`inline-block size-3 rounded-full ${
socket === "connected" ? "bg-signal" : socket === "connecting" ? "bg-ink/30" : "bg-alert"
}`}
/>
{socket === "connected" ? "Live" : socket === "connecting" ? "Connecting…" : "Offline — retrying"}
</span>
<Button variant="secondary" size="sm" aria-expanded={showHelp} onClick={() => setShowHelp(!showHelp)}>
Shortcuts (?)
</Button>
</div>
</div>
{showHelp && (
<dl className={`${panel} grid grid-cols-[auto_1fr] gap-x-4 gap-y-1`}>
{shortcuts.map(([key, description]) => (
<div key={key} className="contents">
<dt>
<kbd className="rounded border border-ink/30 bg-chalk px-2 font-bold">{key}</kbd>
</dt>
<dd>{description}</dd>
</div>
))}
</dl>
)}
<div className="grid items-start gap-4 lg:grid-cols-[15rem_minmax(0,1fr)] xl:grid-cols-[15rem_minmax(0,1fr)_20rem]">
<nav aria-label="Games" className={panel}>
<h2 className="text-lg font-bold">Games</h2>
{games.length === 0 ? (
<p className="mt-2">No games yet. Add some on the setup page.</p>
) : (
<ol className="mt-2 flex flex-col gap-1">
{games.map((game, index) => {
const isCurrent = game.id === event.currentGameId;
return (
<li key={game.id}>
<button
type="button"
disabled={pending}
aria-current={isCurrent ? "true" : undefined}
onClick={() => goTo(game.id)}
className={`flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left ${
isCurrent ? "bg-ink font-bold text-white" : "hover:bg-ink/5"
}`}
>
<span aria-hidden>{statusIcon[game.status]}</span>
<span className="min-w-0 flex-1 truncate">
{index + 1}. {game.title}
</span>
<span className={`text-sm ${isCurrent ? "text-white/80" : "text-ink/70"}`}>
{game.status}
</span>
</button>
</li>
);
})}
</ol>
)}
</nav>
<section className={`${panel} flex flex-col gap-4`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-2xl font-bold">
{currentGame ? `${currentGame.title} · ${currentGame.status}` : "No game selected"}
</h2>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" disabled={pending || !previousGame} onClick={() => goTo(previousGame?.id)}>
Previous game
</Button>
<Button disabled={pending || !nextGame} onClick={() => goTo(nextGame?.id)}>
{currentGame ? "Next game" : "Start first game"}
</Button>
{currentGame && (
<Button
variant="secondary"
disabled={pending}
onClick={() =>
run(() =>
setGameStatus(event.id, currentGame.id, currentGame.status === "done" ? "live" : "done"),
)
}
>
{currentGame.status === "done" ? "Reopen game" : "Mark game done"}
</Button>
)}
</div>
</div>
<ErrorText>{error}</ErrorText>
{currentGame ? (
<>
{/* Keyed by game so half-saved edits never carry over to another game. */}
<ScoringGrid key={currentGame.id} eventId={event.id} game={currentGame} teams={teams} />
{currentGame.adminNotes && (
<div className="rounded-md bg-chalk p-3">
<h3 className="text-sm font-bold">Notes (only you see these)</h3>
<p className="whitespace-pre-wrap">{currentGame.adminNotes}</p>
</div>
)}
</>
) : (
<p>
{games.length === 0
? "Add games on the setup page first."
: "Pick a game from the list, or press “Start first game”. It then shows on the scoreboard."}
</p>
)}
</section>
<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>
</section>
<section className={panel}>
<h2 className="text-lg font-bold">Standings</h2>
<p className="text-sm text-ink/70">Always live here, whatever the room sees.</p>
<ol className="mt-2 flex flex-col gap-1">
{standings.map((standing) => (
<li key={standing.teamId} className="flex items-center gap-2">
<span className="w-5 font-bold tabular-nums">{standing.rank}</span>
<span
aria-hidden
className="inline-block size-3 rounded-full"
style={{ backgroundColor: teamsById.get(standing.teamId)?.color }}
/>
<span className="min-w-0 flex-1 truncate">{teamsById.get(standing.teamId)?.name}</span>
<span className="font-bold tabular-nums">{formatScore(standing.total)}</span>
</li>
))}
</ol>
</section>
<section className={panel}>
<h2 className="mb-2 text-lg font-bold">Bonus and penalty</h2>
<BonusPanel view={view} />
</section>
</div>
</div>
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { saveAdminScore } from "@/app/admin/live-actions";
import { BlockInput, NumberStepper } from "@/components/ui/score-inputs";
import { formatScore } from "@/lib/criteria";
import type { LiveGame, LiveView } from "@/lib/live-view";
import { ErrorText } from "./error-text";
// An edit the server hasn't echoed back yet. Shown instead of the server's value so
// taps feel instant (optimistic UI).
type PendingEdit = { value: number | null; version: number; savedAt: number | null };
const cellKey = (teamId: string, criterionId: string) => `${teamId}|${criterionId}`;
// A saved edit normally disappears when the refreshed data matches it. If it never
// matches (someone else changed the cell), stop masking the server's value after this long.
const STALE_MS = 5000;
export function ScoringGrid({
eventId,
game,
teams,
}: {
eventId: string;
game: LiveGame;
teams: LiveView["teams"];
}) {
const router = useRouter();
const [pending, setPending] = useState<Record<string, PendingEdit>>({});
const [savingCount, setSavingCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const versions = useRef(0);
// Drop optimistic edits once the server data has caught up with them.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reconciling local edits with fresh server props
setPending((current) => {
const now = Date.now();
const kept = Object.entries(current).filter(([key, edit]) => {
if (edit.savedAt === null) return true;
const [teamId, criterionId] = key.split("|");
const serverValue = game.cells[teamId]?.[criterionId]?.adminValue ?? null;
return serverValue !== edit.value && now - edit.savedAt < STALE_MS;
});
return kept.length === Object.keys(current).length ? current : Object.fromEntries(kept);
});
}, [game]);
const save = (teamId: string, criterionId: string, value: number | null) => {
const key = cellKey(teamId, criterionId);
const version = ++versions.current;
setPending((current) => ({ ...current, [key]: { value, version, savedAt: null } }));
setSavingCount((count) => count + 1);
setError(null);
// Only the newest edit of a cell may update or clear its pending state.
const settle = (savedAt: number | null) =>
setPending((current) => {
if (current[key]?.version !== version) return current;
if (savedAt === null) {
return Object.fromEntries(Object.entries(current).filter(([other]) => other !== key));
}
return { ...current, [key]: { ...current[key], savedAt } };
});
saveAdminScore(eventId, game.id, teamId, criterionId, value)
.then((result) => {
if (result.ok) {
settle(Date.now());
router.refresh();
} else {
settle(null);
setError(result.error);
}
})
.catch(() => {
settle(null);
setError("That score wasn't saved — couldn't reach the server. Check your connection and enter it again.");
})
.finally(() => setSavingCount((count) => count - 1));
};
if (game.criteria.length === 0) {
return <p>This game has no criteria yet. Add some on the setup page.</p>;
}
if (teams.length === 0) {
return <p>No teams yet. Add teams on the setup page.</p>;
}
const judged = game.scoringMode === "judged";
return (
<div className="flex flex-col gap-2">
<div className="overflow-x-auto">
<table className="w-full border-separate border-spacing-y-1 text-left">
<thead>
<tr className="text-sm">
<th scope="col" className="pr-3 font-bold">
Team
</th>
{game.criteria.map((criterion) => (
<th key={criterion.id} scope="col" className="px-2 align-bottom font-bold">
{criterion.label}
{criterion.description && (
<span className="block font-normal text-ink/70">{criterion.description}</span>
)}
</th>
))}
<th scope="col" className="px-2 text-right font-bold">
Total
</th>
<th scope="col" className="pl-2 text-right font-bold">
Points
</th>
</tr>
</thead>
<tbody>
{teams.map((team) => {
const result = game.results[team.id];
return (
<tr key={team.id} className="bg-chalk">
<th scope="row" className="rounded-l-md py-2 pr-3 pl-2 font-bold whitespace-nowrap">
<span
aria-hidden
className="mr-2 inline-block size-3 rounded-full align-middle"
style={{ backgroundColor: team.color }}
/>
{team.name}
</th>
{game.criteria.map((criterion) => {
const cell = game.cells[team.id]?.[criterion.id];
const edit = pending[cellKey(team.id, criterion.id)];
const value = edit ? edit.value : (cell?.adminValue ?? null);
const inputProps = {
range: criterion,
value,
label: `${criterion.label} for ${team.name}`,
onChange: (next: number | null) => save(team.id, criterion.id, next),
};
return (
<td key={criterion.id} className="px-2 py-2 align-top">
{criterion.inputType === "blocks" ? (
<BlockInput compact color={team.color} {...inputProps} />
) : (
<NumberStepper {...inputProps} />
)}
{judged && (
<span className="mt-1 block text-sm text-ink/70">
{cell?.judgeAverage == null
? "No judge scores"
: `Judges: ${formatScore(cell.judgeAverage)} (${cell.judgeCount})`}
{value !== null && " · overridden"}
</span>
)}
</td>
);
})}
<td className="px-2 py-2 text-right font-bold tabular-nums">
{result?.raw == null ? "–" : formatScore(result.raw)}
</td>
<td className="rounded-r-md py-2 pr-2 pl-2 text-right font-bold tabular-nums">
{result?.raw == null ? "–" : formatScore(result.points)}
{game.pointsMode === "placement" && result?.placement != null && (
<span className="block text-sm font-normal text-ink/70">place {result.placement}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p role="status" className="text-sm font-bold">
{savingCount > 0 ? "Saving…" : error ? "" : "All scores saved ✓"}
</p>
<ErrorText>{error}</ErrorText>
{judged && (
<p className="text-sm text-ink/70">
This is a judged game. A value you enter here overrides the judges&apos; average for that
cell; clear it to use the judges again.
</p>
)}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { readableTextColor } from "@/lib/color";
import { blockValues, formatScore, snapToStep, type CriterionRange } from "@/lib/criteria";
// The two ways to enter a score (PRD §3). Shared by the control room and the judge page.
type ScoreInputProps = {
range: CriterionRange;
value: number | null;
/** null = clear the score. */
onChange: (value: number | null) => void;
label: string;
disabled?: boolean;
/** Fill colour for the selected block, normally the team's colour. */
color?: string;
};
/** A row of buttons, one per allowed value. Tapping the selected one again clears it. */
export function BlockInput({
range,
value,
onChange,
label,
disabled,
color = "#14213D",
compact = false,
}: ScoreInputProps & { compact?: boolean }) {
return (
<div role="group" aria-label={label} className="flex gap-1">
{blockValues(range).map((option) => {
const selected = value === option;
return (
<button
key={option}
type="button"
disabled={disabled}
aria-pressed={selected}
onClick={() => onChange(selected ? null : option)}
className={`min-h-11 rounded-md border text-base font-bold tabular-nums transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
compact ? "min-w-9 px-1" : "min-w-11 flex-1 px-2"
} ${selected ? "border-transparent" : "border-ink/25 bg-white hover:bg-ink/5"}`}
style={selected ? { backgroundColor: color, color: readableTextColor(color) } : undefined}
>
{formatScore(option)}
</button>
);
})}
</div>
);
}
/** − [number] + for free numeric scores. Typed values snap to the criterion's step and range. */
export function NumberStepper({ range, value, onChange, label, disabled }: ScoreInputProps) {
// While the field has focus it shows what was typed; otherwise it mirrors the saved value.
const [draft, setDraft] = useState<string | null>(null);
const commit = (text: string) => {
setDraft(null);
if (text.trim() === "") {
if (value !== null) onChange(null);
return;
}
const typed = Number(text);
if (Number.isNaN(typed)) return;
const next = snapToStep(typed, range);
if (next !== value) onChange(next);
};
// From empty, either button starts at the minimum rather than jumping to min ± step.
const nudge = (direction: 1 | -1) =>
onChange(value === null ? range.min : snapToStep(value + direction * range.step, range));
const buttonClass =
"min-h-11 min-w-11 rounded-md border border-ink/25 bg-white text-xl font-bold hover:bg-ink/5 disabled:cursor-not-allowed disabled:opacity-60";
return (
<div role="group" aria-label={label} className="flex items-center gap-1">
<button
type="button"
aria-label={`Decrease ${label}`}
disabled={disabled || (value !== null && value <= range.min)}
onClick={() => nudge(-1)}
className={buttonClass}
>
−
</button>
<input
type="number"
inputMode="decimal"
aria-label={label}
disabled={disabled}
min={range.min}
max={range.max}
step={range.step}
value={draft ?? (value === null ? "" : formatScore(value))}
placeholder="–"
onChange={(event) => setDraft(event.target.value)}
onBlur={(event) => commit(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
className="min-h-11 w-20 rounded-md border border-ink/25 bg-white px-2 text-center text-base font-bold tabular-nums disabled:opacity-60"
/>
<button
type="button"
aria-label={`Increase ${label}`}
disabled={disabled || (value !== null && value >= range.max)}
onClick={() => nudge(1)}
className={buttonClass}
>
+
</button>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { blockValues, formatScore, snapToStep } from "./criteria";
describe("blockValues", () => {
it("lists every step from min to max", () => {
expect(blockValues({ min: 1, max: 5, step: 1 })).toEqual([1, 2, 3, 4, 5]);
expect(blockValues({ min: 0, max: 1, step: 1 })).toEqual([0, 1]);
});
it("has no float noise on fractional steps", () => {
expect(blockValues({ min: 0, max: 0.5, step: 0.1 })).toEqual([0, 0.1, 0.2, 0.3, 0.4, 0.5]);
});
});
describe("snapToStep", () => {
const range = { min: 0, max: 20, step: 0.5 };
it("snaps to the nearest step", () => {
expect(snapToStep(7.3, range)).toBe(7.5);
expect(snapToStep(7.2, range)).toBe(7);
expect(snapToStep(3, { min: 1, max: 5, step: 1 })).toBe(3);
});
it("clamps to the range", () => {
expect(snapToStep(-4, range)).toBe(0);
expect(snapToStep(99, range)).toBe(20);
});
it("measures steps from min, not from zero", () => {
expect(snapToStep(4, { min: 1, max: 9, step: 2 })).toBe(5);
});
});
describe("formatScore", () => {
it("trims to at most 2 decimals", () => {
expect(formatScore(4)).toBe("4");
expect(formatScore(4.5)).toBe("4.5");
expect(formatScore(13 / 3)).toBe("4.33");
});
});
+21
View File
@@ -0,0 +1,21 @@
export type CriterionRange = { min: number; max: number; step: number };
// Scores are stored as numeric(8,2), so 2dp is exact and removes float noise (0.1 + 0.2).
const round2 = (value: number) => Math.round(value * 100) / 100;
/** Every allowed value from min to max, e.g. 1,2,3,4,5 — one block button each. */
export function blockValues({ min, max, step }: CriterionRange): number[] {
const count = Math.round((max - min) / step);
return Array.from({ length: count + 1 }, (_, index) => round2(min + index * step));
}
/** Clamps into [min, max] and snaps to the nearest step, so a typed 7.3 on step 0.5 becomes 7.5. */
export function snapToStep(value: number, { min, max, step }: CriterionRange): number {
const clamped = Math.min(max, Math.max(min, value));
return Math.min(max, round2(min + Math.round((clamped - min) / step) * step));
}
/** Shows 4.33 as "4.33", 4.5 as "4.5" and 4 as "4". */
export function formatScore(value: number): string {
return String(round2(value));
}
+141
View File
@@ -0,0 +1,141 @@
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 type { Visibility } from "@/lib/scoreboard";
// The control room's data, shaped on the server so the client component holds no score math
// (CLAUDE.md rule 5) and never receives tokens or rows it doesn't display.
export type LiveCriterion = {
id: string;
label: string;
description: string | null;
inputType: "blocks" | "number";
min: number;
max: number;
step: number;
};
export type LiveCell = CellResult & {
/** The admin's own row: the score in a direct game, the override in a judged game. */
adminValue: number | null;
};
export type LiveGame = {
id: string;
title: string;
status: GameStatus;
scoringMode: ScoringMode;
pointsMode: PointsMode;
multiplier: number;
adminNotes: string | null;
criteria: LiveCriterion[];
/** teamId → criterionId → cell */
cells: Record<string, Record<string, LiveCell>>;
/** teamId → this game's result */
results: Record<string, { raw: number | null; placement: number | null; points: number }>;
};
export type LiveView = {
event: {
id: string;
name: string;
scoreboardToken: string;
visibility: Visibility;
currentGameId: string | null;
};
teams: Array<{ id: string; name: string; color: string }>;
games: Array<Pick<LiveGame, "id" | "title" | "status">>;
/** Full detail for the current game only. */
currentGame: LiveGame | null;
standings: Array<{ teamId: string; rank: number; total: number; adjustmentPoints: number }>;
/** Newest first. */
adjustments: Array<{ id: string; teamId: string; points: number; reason: string | null }>;
};
const RECENT_ADJUSTMENTS = 12;
export function buildLiveView(data: EventData): LiveView {
const result = computeEvent(toScoringInput(data));
const { event } = data;
const game = data.games.find((candidate) => candidate.id === event.current_game_id);
let currentGame: LiveGame | null = null;
if (game) {
const adminValues = new Map(
data.scores
.filter((score) => score.game_id === game.id && score.judge_id === null)
.map((score) => [`${score.team_id}|${score.criterion_id}`, score.value]),
);
const gameResult = result.games[game.id];
currentGame = {
id: game.id,
title: game.title,
status: game.status,
scoringMode: game.scoring_mode,
pointsMode: game.points_mode,
multiplier: game.multiplier,
adminNotes: game.admin_notes,
criteria: data.criteria
.filter((criterion) => criterion.game_id === game.id)
.map((criterion) => ({
id: criterion.id,
label: criterion.label,
description: criterion.description,
inputType: criterion.input_type,
min: criterion.min_value,
max: criterion.max_value,
step: criterion.step,
})),
cells: Object.fromEntries(
data.teams.map((team) => [
team.id,
Object.fromEntries(
Object.entries(gameResult.teams[team.id].cells).map(([criterionId, cell]) => [
criterionId,
{ ...cell, adminValue: adminValues.get(`${team.id}|${criterionId}`) ?? null },
]),
),
]),
),
results: Object.fromEntries(
data.teams.map((team) => {
const { raw, placement, points } = gameResult.teams[team.id];
return [team.id, { raw, placement, points }];
}),
),
};
}
return {
event: {
id: event.id,
name: event.name,
scoreboardToken: event.scoreboard_token,
visibility: event.scoreboard_visibility,
currentGameId: currentGame?.id ?? null,
},
teams: data.teams.map((team) => ({ id: team.id, name: team.name, color: team.color })),
games: data.games.map((candidate) => ({
id: candidate.id,
title: candidate.title,
status: candidate.status,
})),
currentGame,
standings: result.standings.map(({ teamId, rank, total, adjustmentPoints }) => ({
teamId,
rank,
total,
adjustmentPoints,
})),
adjustments: data.adjustments
.slice(-RECENT_ADJUSTMENTS)
.reverse()
.map((adjustment) => ({
id: adjustment.id,
teamId: adjustment.team_id,
points: adjustment.points,
reason: adjustment.reason,
})),
};
}