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:
@@ -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))} />;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' average for that
|
||||
cell; clear it to use the judges again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user