diff --git a/src/app/admin/events/[eventId]/live/page.tsx b/src/app/admin/events/[eventId]/live/page.tsx new file mode 100644 index 0000000..055fb40 --- /dev/null +++ b/src/app/admin/events/[eventId]/live/page.tsx @@ -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 ; +} diff --git a/src/app/admin/events/[eventId]/page.tsx b/src/app/admin/events/[eventId]/page.tsx index 94b8aea..f0df0c0 100644 --- a/src/app/admin/events/[eventId]/page.tsx +++ b/src/app/admin/events/[eventId]/page.tsx @@ -19,13 +19,16 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev const { event, teams, games } = setup; return ( -
+
← All game nights

{event.name}

+ + Control room + ) { return (
-
+
{publicEnv.NEXT_PUBLIC_APP_NAME} @@ -23,7 +23,8 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
-
{children}
+ {/* Pages set their own width: setup is narrow, the control room is wide. */} +
{children}
); } diff --git a/src/app/admin/live-actions.ts b/src/app/admin/live-actions.ts new file mode 100644 index 0000000..c53e7cc --- /dev/null +++ b/src/app/admin/live-actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 0d0023b..a4c9c32 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -15,7 +15,7 @@ export default async function AdminHome() { if (error) throw new Error(`Could not load events: ${error.message}`); return ( -
+

Your game nights

{events.length === 0 ? ( diff --git a/src/components/admin/bonus-panel.tsx b/src/components/admin/bonus-panel.tsx new file mode 100644 index 0000000..e5d330f --- /dev/null +++ b/src/components/admin/bonus-panel.tsx @@ -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

Add teams on the setup page to give bonus points.

; + + return ( +
+
    + {teams.map((team) => ( +
  • + + + {team.name} + + {QUICK_AMOUNTS.map((points) => ( + + ))} +
  • + ))} +
+ +
{ + submitEvent.preventDefault(); + add(teamId, amount === "" ? Number.NaN : Number(amount), reason, () => { + setAmount(""); + setReason(""); + }); + }} + > + Custom amount +
+ + setAmount(changeEvent.target.value)} + className={inputClass} + /> +
+ setReason(changeEvent.target.value)} + className={inputClass} + /> + +
+ {error} + + {adjustments.length > 0 && ( +
+

Recent

+
    + {adjustments.map((adjustment) => ( +
  • + + + {signed(adjustment.points)} + {" "} + {teamName(adjustment.teamId)} + {adjustment.reason && — {adjustment.reason}} + + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/admin/control-room.tsx b/src/components/admin/control-room.tsx new file mode 100644 index 0000000..3865776 --- /dev/null +++ b/src/components/admin/control-room.tsx @@ -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 ( +
+
+
+ + ← Setup + +

{event.name} · control room

+
+
+ + + {socket === "connected" ? "Live" : socket === "connecting" ? "Connecting…" : "Offline — retrying"} + + +
+
+ + {showHelp && ( +
+ {shortcuts.map(([key, description]) => ( +
+
+ {key} +
+
{description}
+
+ ))} +
+ )} + +
+ + +
+
+

+ {currentGame ? `${currentGame.title} · ${currentGame.status}` : "No game selected"} +

+
+ + + {currentGame && ( + + )} +
+
+ {error} + + {currentGame ? ( + <> + {/* Keyed by game so half-saved edits never carry over to another game. */} + + {currentGame.adminNotes && ( +
+

Notes (only you see these)

+

{currentGame.adminNotes}

+
+ )} + + ) : ( +

+ {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."} +

+ )} +
+ +
+
+

Scoreboard

+

+ Scores are {visibilityLabel[event.visibility].toLowerCase()} to the room. +

+
+ Open scoreboard + +
+ +
+

Standings

+

Always live here, whatever the room sees.

+
    + {standings.map((standing) => ( +
  1. + {standing.rank} + + {teamsById.get(standing.teamId)?.name} + {formatScore(standing.total)} +
  2. + ))} +
+
+ +
+

Bonus and penalty

+ +
+
+
+
+ ); +} diff --git a/src/components/admin/scoring-grid.tsx b/src/components/admin/scoring-grid.tsx new file mode 100644 index 0000000..5601845 --- /dev/null +++ b/src/components/admin/scoring-grid.tsx @@ -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>({}); + const [savingCount, setSavingCount] = useState(0); + const [error, setError] = useState(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 ( +
+
+ + + + + {game.criteria.map((criterion) => ( + + ))} + + + + + + {teams.map((team) => { + const result = game.results[team.id]; + return ( + + + {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 ( + + ); + })} + + + + ); + })} + +
+ Team + + {criterion.label} + {criterion.description && ( + {criterion.description} + )} + + Total + + Points +
+ + {team.name} + + {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, + })), + }; +}