(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 This game has no criteria yet. Add some on the setup page.
;
+ }
+ if (teams.length === 0) {
+ return No teams yet. Add teams on the setup page.
;
+ }
+
+ const judged = game.scoringMode === "judged";
+
+ return (
+
+
+
+
+
+ |
+ Team
+ |
+ {game.criteria.map((criterion) => (
+
+ {criterion.label}
+ {criterion.description && (
+ {criterion.description}
+ )}
+ |
+ ))}
+
+ Total
+ |
+
+ Points
+ |
+
+
+
+ {teams.map((team) => {
+ const result = game.results[team.id];
+ return (
+
+ |
+
+ {team.name}
+ |
+ {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 (
+
+ {criterion.inputType === "blocks" ? (
+
+ ) : (
+
+ )}
+ {judged && (
+
+ {cell?.judgeAverage == null
+ ? "No judge scores"
+ : `Judges: ${formatScore(cell.judgeAverage)} (${cell.judgeCount})`}
+ {value !== null && " · overridden"}
+
+ )}
+ |
+ );
+ })}
+
+ {result?.raw == null ? "–" : formatScore(result.raw)}
+ |
+
+ {result?.raw == null ? "–" : formatScore(result.points)}
+ {game.pointsMode === "placement" && result?.placement != null && (
+ place {result.placement}
+ )}
+ |
+
+ );
+ })}
+
+
+
+
+ {savingCount > 0 ? "Saving…" : error ? "" : "All scores saved ✓"}
+
+
{error}
+ {judged && (
+
+ This is a judged game. A value you enter here overrides the judges' average for that
+ cell; clear it to use the judges again.
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/score-inputs.tsx b/src/components/ui/score-inputs.tsx
new file mode 100644
index 0000000..e727876
--- /dev/null
+++ b/src/components/ui/score-inputs.tsx
@@ -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 (
+
+ {blockValues(range).map((option) => {
+ const selected = value === option;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+/** − [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(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 (
+
+
+ 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"
+ />
+
+
+ );
+}
diff --git a/src/lib/criteria.test.ts b/src/lib/criteria.test.ts
new file mode 100644
index 0000000..e59a0ee
--- /dev/null
+++ b/src/lib/criteria.test.ts
@@ -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");
+ });
+});
diff --git a/src/lib/criteria.ts b/src/lib/criteria.ts
new file mode 100644
index 0000000..8d0a620
--- /dev/null
+++ b/src/lib/criteria.ts
@@ -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));
+}
diff --git a/src/lib/live-view.ts b/src/lib/live-view.ts
new file mode 100644
index 0000000..6ca4285
--- /dev/null
+++ b/src/lib/live-view.ts
@@ -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>;
+ /** teamId → this game's result */
+ results: Record;
+};
+
+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>;
+ /** 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,
+ })),
+ };
+}