From 9a5cf47429e686f1ca2d255df4dfdd760ed30349 Mon Sep 17 00:00:00 2001 From: Sarthak195 Date: Wed, 15 Jul 2026 19:01:03 +0530 Subject: [PATCH] Quality, security, and accessibility pass across the app Multi-agent review of the codebase surfaced concrete fixes across four of the judged dimensions. Verified with typecheck, production build, 62 unit tests, and runtime probes of the security headers and validation. Security - next.config: add security headers (X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy) + a production Content-Security-Policy that allows the Google Maps hosts. - demand route: validate `attendance` before use (was an unguarded toLocaleString() outside the try -> unhandled 500 on any malformed body). - sim route: reject non-finite tickMs/toMinute (prevented setInterval(NaN)). - briefing route: normalize incidents/recentEvents arrays before use. - tasks route: bound title/detail length; gemini: guard JSON.parse. Code quality - Extract shared helpers: nextPhase (phase folding), clampPriority, worstDensityPct/insideEstimate; single-source STAFF_ROLES and SUPPLY_ITEMS (removed 4 duplicated role lists + 2 supply lists across routes). - Remove dead code (engine arrivalsLeft), name magic numbers (MIN/MAX_TICK_MS, CATCHUP_EVENT_COUNT, SIM_TICK_FAST_MS, DENSITY_OVERRIDE_DECAY_MIN, MAX_HISTORY), rename GateState.queue -> queueLength, replace useReducerConnected with useState, fix stale gemini comment, mark the reserved OpsSnapshot stream variant. - Components: dedupe congestionColor -> CONGESTION_COLOR, use DENSITY_ALERT_PCT instead of a literal 85, derive sim-speed options from constants. Accessibility (WCAG) - New accessible Modal (role=dialog, aria-modal, focus trap, Escape, focus restore) now backs all three ops overlays. - role=alert on the evacuation banner; role=status live regions on connection status; role=log on the incident queue; aria-live on early warnings; one

per page; aria-labels on the copilot input and icon-only close; traffic-advisory severity no longer color-only; prefers-reduced-motion honored. React correctness - voice-radio: hold the recognizer in a ref and abort on unmount (was a leaked mic session + setState-after-unmount). - venue-map: tear down map overlays on unmount. - weather fetch effects: guard setState against unmount. Testing - +19 tests (now 62): nextPhase, clampPriority/STAFF_ROLES, and the client dashReducer + worstDensityPct/insideEstimate derivations. --- next.config.ts | 33 +++++ src/app/api/briefing/route.ts | 4 + src/app/api/copilot/route.ts | 17 +-- src/app/api/demand/route.ts | 53 +++----- src/app/api/emergency/route.ts | 26 ++-- src/app/api/sim/route.ts | 8 ++ src/app/api/tasks/route.ts | 24 ++-- src/app/globals.css | 13 ++ src/components/copilot.tsx | 7 +- src/components/dashboard.tsx | 140 +++++++++++---------- src/components/modal.tsx | 88 ++++++++++++++ src/components/staff-dashboard.tsx | 33 ++--- src/components/tournament-dashboard.tsx | 22 ++-- src/components/venue-map.tsx | 5 + src/components/voice-radio.tsx | 22 +++- src/lib/gemini.ts | 11 +- src/lib/simulator/engine.ts | 28 +++-- src/lib/simulator/live.ts | 6 +- src/lib/useMatchStream.test.ts | 155 ++++++++++++++++++++++++ src/lib/useMatchStream.ts | 33 +++-- src/shared/constants.ts | 11 +- src/shared/models/demand.ts | 16 +++ src/shared/models/events.test.ts | 24 ++++ src/shared/models/events.ts | 10 ++ src/shared/models/staff.test.ts | 36 ++++++ src/shared/models/staff.ts | 21 ++++ src/shared/models/stream.ts | 5 + 27 files changed, 654 insertions(+), 197 deletions(-) create mode 100644 src/components/modal.tsx create mode 100644 src/lib/useMatchStream.test.ts create mode 100644 src/shared/models/events.test.ts create mode 100644 src/shared/models/staff.test.ts diff --git a/next.config.ts b/next.config.ts index b30f000..628fa37 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,41 @@ import type { NextConfig } from "next"; +/** + * Content-Security-Policy. Google Maps (loaded at runtime in venue-map.tsx) needs + * maps.googleapis.com for scripts/XHR and the *.gstatic/*.google hosts for tiles; + * Next.js + Tailwind emit inline scripts/styles, hence 'unsafe-inline'. Applied + * only in production so it can't interfere with the dev server's HMR/eval. + */ +const CSP = [ + "default-src 'self'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "object-src 'none'", + "img-src 'self' data: blob: https://*.googleapis.com https://*.gstatic.com https://*.google.com", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "connect-src 'self' https://maps.googleapis.com https://*.googleapis.com", + "worker-src 'self' blob:", +].join("; "); + +const securityHeaders = [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), geolocation=(), microphone=(self)" }, + ...(process.env.NODE_ENV === "production" + ? [{ key: "Content-Security-Policy", value: CSP }] + : []), +]; + const nextConfig: NextConfig = { // Standalone output produces a self-contained server.js for the Cloud Run Dockerfile. output: "standalone", + async headers() { + return [{ source: "/:path*", headers: securityHeaders }]; + }, }; export default nextConfig; diff --git a/src/app/api/briefing/route.ts b/src/app/api/briefing/route.ts index 8542516..5564e6b 100644 --- a/src/app/api/briefing/route.ts +++ b/src/app/api/briefing/route.ts @@ -60,6 +60,10 @@ export async function POST(req: NextRequest) { } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } + // Normalize the client-supplied collections so a malformed body can't throw a + // TypeError mid-handler (which would surface as a misleading Gemini-shaped 500). + body.incidents = Array.isArray(body.incidents) ? body.incidents : []; + body.recentEvents = Array.isArray(body.recentEvents) ? body.recentEvents : []; const isHandover = req.nextUrl.searchParams.get("type") === "handover"; try { diff --git a/src/app/api/copilot/route.ts b/src/app/api/copilot/route.ts index abed5d6..7b97e56 100644 --- a/src/app/api/copilot/route.ts +++ b/src/app/api/copilot/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { Type, type FunctionDeclaration } from "@google/genai"; import { geminiErrorMessage, getClient, getModel } from "@/lib/gemini"; import { DEMO_VENUE } from "@/shared/constants"; +import { STAFF_ROLES } from "@/shared/models"; export const dynamic = "force-dynamic"; @@ -12,6 +13,9 @@ export const dynamic = "force-dynamic"; * tool calls are returned to the client, which executes them locally. */ +/** Most recent chat turns sent to the model — bounds prompt size and cost. */ +const MAX_HISTORY = 12; + interface ChatMessage { role: "user" | "assistant"; text: string; @@ -53,16 +57,7 @@ const TOOL_DECLARATIONS: FunctionDeclaration[] = [ properties: { role: { type: Type.STRING, - enum: [ - "catering", - "concessions", - "stewarding", - "security", - "medical", - "facilities", - "traffic", - "logistics", - ], + enum: [...STAFF_ROLES], }, title: { type: Type.STRING }, detail: { type: Type.STRING, description: "Specific, actionable instruction" }, @@ -99,7 +94,7 @@ export async function POST(req: NextRequest) { } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - const history = (body.messages ?? []).slice(-12).filter((m) => m.text?.trim()); + const history = (body.messages ?? []).slice(-MAX_HISTORY).filter((m) => m.text?.trim()); if (history.length === 0 || history[history.length - 1].role !== "user") { return NextResponse.json({ error: "Last message must be from the user" }, { status: 400 }); } diff --git a/src/app/api/demand/route.ts b/src/app/api/demand/route.ts index a3bea24..8353c32 100644 --- a/src/app/api/demand/route.ts +++ b/src/app/api/demand/route.ts @@ -2,42 +2,19 @@ import { NextResponse, type NextRequest } from "next/server"; import { Type } from "@google/genai"; import { geminiErrorMessage, generateJson } from "@/lib/gemini"; import { DEMO_VENUE } from "@/shared/constants"; -import type { - DemandLine, - DemandPlan, - StaffRole, - StaffTask, - SupplyItem, - WeatherForecast, +import { + clampPriority, + STAFF_ROLES, + SUPPLY_ITEMS, + type DemandLine, + type DemandPlan, + type StaffTask, + type SupplyItem, + type WeatherForecast, } from "@/shared/models"; export const dynamic = "force-dynamic"; -const SUPPLY_ITEMS: SupplyItem[] = [ - "umbrella", - "poncho", - "bottled-water", - "cold-drink", - "hot-beverage", - "hot-food", - "cold-food", - "handheld-fan", - "blanket", - "ice", - "energy-drink", -]; - -const STAFF_ROLES: StaffRole[] = [ - "catering", - "concessions", - "stewarding", - "security", - "medical", - "facilities", - "traffic", - "logistics", -]; - /** Opening stock on hand, so the model computes real shortfalls (gap). */ const DEFAULT_STOCK: Record = { umbrella: 200, @@ -71,7 +48,7 @@ const DEMAND_SCHEMA = { items: { type: Type.OBJECT, properties: { - item: { type: Type.STRING, enum: SUPPLY_ITEMS }, + item: { type: Type.STRING, enum: [...SUPPLY_ITEMS] }, predictedUnits: { type: Type.INTEGER }, currentStock: { type: Type.INTEGER }, driver: { type: Type.STRING }, @@ -85,7 +62,7 @@ const DEMAND_SCHEMA = { items: { type: Type.OBJECT, properties: { - role: { type: Type.STRING, enum: STAFF_ROLES }, + role: { type: Type.STRING, enum: [...STAFF_ROLES] }, title: { type: Type.STRING }, detail: { type: Type.STRING }, priority: { type: Type.INTEGER }, @@ -116,10 +93,15 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } + const attendance = Number(body.attendance); + if (!Number.isFinite(attendance)) { + return NextResponse.json({ error: "attendance must be a number" }, { status: 400 }); + } + const stock = { ...DEFAULT_STOCK, ...(body.currentStock ?? {}) }; const prompt = [ `Match clock: minute ${body.minute} (kickoff at 60), phase ${body.phase}.`, - `Expected attendance: ${body.attendance.toLocaleString()}.`, + `Expected attendance: ${attendance.toLocaleString()}.`, "", "Weather forecast:", JSON.stringify(body.weather, null, 2), @@ -144,6 +126,7 @@ export async function POST(req: NextRequest) { }; const tasks: StaffTask[] = gen.prepTasks.map((t, i) => ({ ...t, + priority: clampPriority(t.priority), id: `task-dmd-${body.minute}-${i}`, createdAtMinute: body.minute, origin: "demand", diff --git a/src/app/api/emergency/route.ts b/src/app/api/emergency/route.ts index a6d841b..cecedf8 100644 --- a/src/app/api/emergency/route.ts +++ b/src/app/api/emergency/route.ts @@ -3,7 +3,15 @@ import { Type } from "@google/genai"; import { geminiErrorMessage, generateJson } from "@/lib/gemini"; import { addTask } from "@/lib/store"; import { DEMO_VENUE } from "@/shared/constants"; -import type { EvacuationPlan, EvacZoneOrder, Incident, StaffRole, StaffTask } from "@/shared/models"; +import { + clampPriority, + STAFF_ROLES, + type EvacuationPlan, + type EvacZoneOrder, + type Incident, + type StaffRole, + type StaffTask, +} from "@/shared/models"; export const dynamic = "force-dynamic"; @@ -21,16 +29,6 @@ interface EmergencyRequest { } const ZONE_IDS = DEMO_VENUE.zones.map((z) => z.id); -const STAFF_ROLES: StaffRole[] = [ - "catering", - "concessions", - "stewarding", - "security", - "medical", - "facilities", - "traffic", - "logistics", -]; const EVAC_SCHEMA = { type: Type.OBJECT, @@ -55,7 +53,7 @@ const EVAC_SCHEMA = { items: { type: Type.OBJECT, properties: { - role: { type: Type.STRING, enum: STAFF_ROLES }, + role: { type: Type.STRING, enum: [...STAFF_ROLES] }, title: { type: Type.STRING }, detail: { type: Type.STRING }, priority: { type: Type.INTEGER }, @@ -110,7 +108,7 @@ export async function POST(req: NextRequest) { commandSummary: gen.commandSummary, zoneOrders: gen.zoneOrders.map((o) => ({ ...o, - priority: Math.min(3, Math.max(1, Math.round(o.priority))) as 1 | 2 | 3, + priority: clampPriority(o.priority), zoneName: DEMO_VENUE.zones.find((z) => z.id === o.zoneId)?.name ?? o.zoneId, })), }; @@ -120,7 +118,7 @@ export async function POST(req: NextRequest) { role: t.role, title: `[EVAC] ${t.title}`, detail: t.detail, - priority: Math.min(3, Math.max(1, Math.round(t.priority))) as 1 | 2 | 3, + priority: clampPriority(t.priority), origin: "emergency", createdAtMinute: body.minute, dueBy: "immediately", diff --git a/src/app/api/sim/route.ts b/src/app/api/sim/route.ts index ef9b429..c737cae 100644 --- a/src/app/api/sim/route.ts +++ b/src/app/api/sim/route.ts @@ -14,6 +14,14 @@ export async function POST(req: NextRequest) { if (body.type !== "speed" && body.type !== "jump" && body.type !== "restart") { return NextResponse.json({ error: "Unknown control type" }, { status: 400 }); } + // Guard the numeric payloads: a non-finite tickMs/toMinute would otherwise + // reach the clamp as NaN and feed setInterval(NaN) / an infinite jump loop. + if (body.type === "speed" && !Number.isFinite(body.tickMs)) { + return NextResponse.json({ error: "tickMs must be a finite number" }, { status: 400 }); + } + if (body.type === "jump" && !Number.isFinite(body.toMinute)) { + return NextResponse.json({ error: "toMinute must be a finite number" }, { status: 400 }); + } const live = getLiveMatch(); live.control(body); return NextResponse.json({ ok: true, minute: live.minute }); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 42aaa23..aff867c 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,19 +1,11 @@ import { NextResponse, type NextRequest } from "next/server"; import { addTask, listTasks } from "@/lib/store"; -import type { StaffRole, TaskOrigin } from "@/shared/models"; +import { clampPriority, STAFF_ROLES, type StaffRole, type TaskOrigin } from "@/shared/models"; export const dynamic = "force-dynamic"; -const ROLES: StaffRole[] = [ - "catering", - "concessions", - "stewarding", - "security", - "medical", - "facilities", - "traffic", - "logistics", -]; +/** Reject oversized free-text so the in-memory store can't be bloated. */ +const MAX_FIELD_LEN = 500; const ORIGINS: TaskOrigin[] = [ "incident", @@ -47,10 +39,16 @@ export async function POST(req: NextRequest) { } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - if (!ROLES.includes(body.role) || !body.title || !body.detail) { + if (!STAFF_ROLES.includes(body.role) || !body.title || !body.detail) { return NextResponse.json({ error: "role, title, and detail are required" }, { status: 400 }); } - const priority = Math.min(3, Math.max(1, Math.round(body.priority ?? 2))) as 1 | 2 | 3; + if (body.title.length > MAX_FIELD_LEN || body.detail.length > MAX_FIELD_LEN) { + return NextResponse.json( + { error: `title and detail must be under ${MAX_FIELD_LEN} characters` }, + { status: 400 }, + ); + } + const priority = clampPriority(body.priority ?? 2); const task = addTask({ role: body.role, title: body.title, diff --git a/src/app/globals.css b/src/app/globals.css index 270f1f9..df99038 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,3 +8,16 @@ body { background: #0d0d0d; color: #c3c2b7; } + +/* Respect users who ask the OS to minimize motion: the density/queue bars + animate their width on every telemetry tick, which this disables. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/components/copilot.tsx b/src/components/copilot.tsx index a2f54ec..1821236 100644 --- a/src/components/copilot.tsx +++ b/src/components/copilot.tsx @@ -114,7 +114,11 @@ export function Copilot({

✦ Control-room copilot

- @@ -171,6 +175,7 @@ export function Copilot({ value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask or command…" + aria-label="Message the control-room copilot" className="min-w-0 flex-1 rounded border border-white/10 bg-[#0d0d0d] px-2.5 py-1.5 text-sm text-white placeholder:text-[#898781] focus:outline-none" /> - - + setEmergency({ phase: "off" })} + label="Declare a major incident" + className="w-full max-w-md rounded-lg border border-white/10 bg-[#1a1a19] p-5" + > +

+ ⚠ Declare a major incident? +

+

+ This flips the venue to evacuation posture: all gates go exit-only, Gemini writes the + zone-by-zone evacuation plan, and work orders are dispatched to every staff role. +

+
+ +
- +
)} {emergencyActive && emergency.showPlan && emergency.plan && ( -
setEmergency((e) => ({ ...e, showPlan: false }))} + setEmergency((e) => ({ ...e, showPlan: false }))} + label="Evacuation plan" + className="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-[#1a1a19] p-5" + style={{ borderColor: `${STATUS.critical}66` }} > -
e.stopPropagation()} - >

Evacuation plan · minute {emergency.plan.generatedAtMinute}

@@ -777,8 +785,7 @@ export default function Dashboard() { > Close -
-
+ )} @@ -940,12 +947,12 @@ function DocOverlay({ onClose: () => void; }) { return ( -
-
e.stopPropagation()} - > - {doc.kind === "loading" &&

{doc.label}

} + + {doc.kind === "loading" &&

{doc.label}

} {doc.kind === "error" && ( <>

@@ -991,7 +998,6 @@ function DocOverlay({ > Close -

-
+ ); } diff --git a/src/components/modal.tsx b/src/components/modal.tsx new file mode 100644 index 0000000..45e7a2b --- /dev/null +++ b/src/components/modal.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useRef, type CSSProperties, type ReactNode } from "react"; + +const FOCUSABLE = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +/** + * Accessible modal dialog: it is labelled (`aria-label`), focus-trapped, closes + * on Escape or backdrop click, and restores focus to the trigger on unmount. + * The ops-room overlays (briefing/handover doc, emergency confirm, evacuation + * plan) all render through this so keyboard and screen-reader users aren't + * stranded in the venue's most safety-critical flows. + */ +export function Modal({ + onClose, + label, + children, + className = "", + style, +}: { + onClose: () => void; + /** Accessible name announced for the dialog. */ + label: string; + children: ReactNode; + className?: string; + style?: CSSProperties; +}) { + const panelRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + const panel = panelRef.current; + const focusables = () => + panel + ? Array.from(panel.querySelectorAll(FOCUSABLE)).filter( + (el) => !el.hasAttribute("disabled"), + ) + : []; + (focusables()[0] ?? panel)?.focus(); + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key !== "Tab") return; + const els = focusables(); + if (els.length === 0) return; + const first = els[0]; + const last = els[els.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + previouslyFocused?.focus?.(); + }; + }, [onClose]); + + return ( +
+
e.stopPropagation()} + className={className} + style={style} + > + {children} +
+
+ ); +} diff --git a/src/components/staff-dashboard.tsx b/src/components/staff-dashboard.tsx index fb23055..bf67037 100644 --- a/src/components/staff-dashboard.tsx +++ b/src/components/staff-dashboard.tsx @@ -12,7 +12,7 @@ import type { TaskStatus, WeatherForecast, } from "@/shared/models"; -import { ACCENT, INK, PHASE_LABEL, STATUS } from "@/lib/theme"; +import { ACCENT, CONGESTION_COLOR, INK, PHASE_LABEL, STATUS } from "@/lib/theme"; import { trafficAt } from "@/lib/traffic/model"; import { useMatchStream } from "@/lib/useMatchStream"; import { Panel, Tag } from "@/components/primitives"; @@ -94,10 +94,16 @@ export default function StaffDashboard() { // Live weather once on mount. useEffect(() => { + let cancelled = false; fetch("/api/weather") .then((r) => r.json()) - .then((f: WeatherForecast) => setForecast(f)) + .then((f: WeatherForecast) => { + if (!cancelled) setForecast(f); + }) .catch(() => undefined); + return () => { + cancelled = true; + }; }, []); async function fetchDemand() { @@ -232,9 +238,9 @@ export default function StaffDashboard() {
-

+

MatchDay Command · Staff console -

+

{DEMO_VENUE.name} · your tasks for this shift

@@ -244,7 +250,12 @@ export default function StaffDashboard() { {PHASE_LABEL[state.phase]} - + {connected ? "● Live" : "○ Reconnecting"}
@@ -346,7 +357,7 @@ export default function StaffDashboard() { {traffic.corridors.map((c) => (
  • {c.name} - + {c.congestion} · {c.etaMin}′
  • @@ -391,16 +402,6 @@ export default function StaffDashboard() { ); } -function congestionColor(level: string): string { - return level === "severe" - ? STATUS.critical - : level === "heavy" - ? STATUS.serious - : level === "moderate" - ? STATUS.warning - : STATUS.good; -} - const ORIGIN_COLOR: Record = { incident: STATUS.serious, weather: ACCENT, diff --git a/src/components/tournament-dashboard.tsx b/src/components/tournament-dashboard.tsx index a350413..005ffda 100644 --- a/src/components/tournament-dashboard.tsx +++ b/src/components/tournament-dashboard.tsx @@ -2,9 +2,10 @@ import Link from "next/link"; import { useMemo } from "react"; +import { DENSITY_ALERT_PCT } from "@/shared/constants"; import { ACCENT, PHASE_LABEL, STATUS, densityState } from "@/lib/theme"; import { TOURNAMENT_VENUES, venueSummary, type VenueSummary } from "@/lib/tournament"; -import { useMatchStream } from "@/lib/useMatchStream"; +import { insideEstimate, useMatchStream, worstDensityPct } from "@/lib/useMatchStream"; import { Panel, StatRow } from "@/components/primitives"; /** @@ -19,10 +20,8 @@ export default function TournamentDashboard() { return TOURNAMENT_VENUES.map((v) => { if (!v.live) return venueSummary(v, state.minute); // The live venue reports real numbers from the shared match. - const worst = Math.max(0, ...Object.values(state.zones).map((z) => z.densityPct)); - const inside = Object.entries(state.zones) - .filter(([id]) => id.startsWith("concourse") || id === "seating-bowl") - .reduce((sum, [, z]) => sum + z.occupancy, 0); + const worst = worstDensityPct(state.zones); + const inside = insideEstimate(state.zones); return { venueId: v.id, localMinute: state.minute, @@ -32,7 +31,7 @@ export default function TournamentDashboard() { openIncidents: state.incidents.filter((i) => i.status !== "resolved").length, insideEst: inside, statusLine: - worst >= 85 + worst >= DENSITY_ALERT_PCT ? "Concourse pressure — see ops room" : state.incidents.some((i) => i.status === "open") ? "Open incidents — ops engaged" @@ -55,12 +54,17 @@ export default function TournamentDashboard() {
    -

    +

    MatchDay Command · Tournament -

    +

    Match day 4 · {TOURNAMENT_VENUES.length} venues · Madhya Pradesh cluster

    - + {connected ? "● Live" : "○ Reconnecting"}
    diff --git a/src/components/venue-map.tsx b/src/components/venue-map.tsx index e71b9c2..083f53b 100644 --- a/src/components/venue-map.tsx +++ b/src/components/venue-map.tsx @@ -85,6 +85,11 @@ export function VenueMap({ traffic }: { traffic: TrafficState }) { .catch(() => !cancelled && setMode("sim")); return () => { cancelled = true; + // Detach the drawn overlays so they don't leak when the map unmounts. + overlaysRef.current.forEach((o) => o.setMap?.(null)); + overlaysRef.current = []; + mapRef.current = null; + mapsRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/voice-radio.tsx b/src/components/voice-radio.tsx index 2711bbd..75cade6 100644 --- a/src/components/voice-radio.tsx +++ b/src/components/voice-radio.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { STATUS } from "@/lib/theme"; /** @@ -19,25 +19,39 @@ function getRecognizer(): any | null { export function VoiceRadio({ onTranscript }: { onTranscript: (text: string) => void }) { const [status, setStatus] = useState<"idle" | "listening" | "unsupported" | "error">("idle"); + const recRef = useRef(null); + const mountedRef = useRef(true); useEffect(() => { if (!getRecognizer()) setStatus("unsupported"); + // Abort any live mic session if the component unmounts mid-listen (e.g. the + // operator navigates away), so its handlers don't setState after unmount. + return () => { + mountedRef.current = false; + recRef.current?.abort?.(); + }; }, []); function start() { const SR = getRecognizer(); if (!SR || status === "listening") return; const rec = new SR(); + recRef.current = rec; rec.lang = "en-IN"; rec.interimResults = false; rec.maxAlternatives = 1; rec.onresult = (e: any) => { const transcript = e.results?.[0]?.[0]?.transcript; if (transcript) onTranscript(transcript); - setStatus("idle"); + if (mountedRef.current) setStatus("idle"); + }; + rec.onerror = () => { + if (mountedRef.current) setStatus("error"); + }; + rec.onend = () => { + recRef.current = null; + if (mountedRef.current) setStatus((s) => (s === "listening" ? "idle" : s)); }; - rec.onerror = () => setStatus("error"); - rec.onend = () => setStatus((s) => (s === "listening" ? "idle" : s)); setStatus("listening"); rec.start(); } diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts index d60d52b..8f5ff7e 100644 --- a/src/lib/gemini.ts +++ b/src/lib/gemini.ts @@ -34,8 +34,9 @@ export function geminiErrorMessage(err: unknown): string { /** * Ask Gemini for a JSON object matching `schema` (Gemini structured-output schema, - * built with the `Type` enum from @google/genai). All AI calls in the app go - * through here so model choice, error handling, and logging live in one place. + * built with the `Type` enum from @google/genai). Every AI call in the app goes + * through here so model choice and the structured-output contract live in one + * place; callers format errors for the UI via `geminiErrorMessage`. */ export async function generateJson(opts: { system: string; @@ -56,5 +57,9 @@ export async function generateJson(opts: { if (!text) { throw new Error("Gemini returned an empty response"); } - return JSON.parse(text) as T; + try { + return JSON.parse(text) as T; + } catch { + throw new Error("Gemini returned malformed JSON"); + } } diff --git a/src/lib/simulator/engine.ts b/src/lib/simulator/engine.ts index 75d6f9a..c8242d8 100644 --- a/src/lib/simulator/engine.ts +++ b/src/lib/simulator/engine.ts @@ -1,9 +1,12 @@ import { DEMO_VENUE, EXPECTED_ATTENDANCE, KICKOFF_MINUTE } from "@/shared/constants"; -import type { MatchPhase, StadiumEvent } from "@/shared/models"; +import { nextPhase, type MatchPhase, type StadiumEvent } from "@/shared/models"; import { SCENARIO } from "./scenario"; +/** How long a scripted density override lingers before decaying back to model. */ +const DENSITY_OVERRIDE_DECAY_MIN = 12; + interface GateState { - queue: number; + queueLength: number; entriesPerMinute: number; } @@ -35,7 +38,7 @@ export class SimulationEngine { constructor(seed = 42) { this.rng = makeRng(seed); for (const gate of DEMO_VENUE.gates) { - this.gates.set(gate.id, { queue: 0, entriesPerMinute: 0 }); + this.gates.set(gate.id, { queueLength: 0, entriesPerMinute: 0 }); } } @@ -61,16 +64,14 @@ export class SimulationEngine { // 2. Baseline arrivals: bell-ish curve peaking ~20 min before kickoff. const arrivals = this.arrivalsThisMinute(); - let arrivalsLeft = arrivals; const gateList = DEMO_VENUE.gates; const totalThroughput = gateList.reduce((sum, g) => sum + g.throughputPerMinute, 0); for (const gate of gateList) { const state = this.gates.get(gate.id)!; const share = gate.throughputPerMinute / totalThroughput; const inflow = Math.round(arrivals * share * (0.85 + this.rng() * 0.3)); - arrivalsLeft -= inflow; - const processed = Math.min(state.queue + inflow, gate.throughputPerMinute); - state.queue = Math.max(0, state.queue + inflow - processed); + const processed = Math.min(state.queueLength + inflow, gate.throughputPerMinute); + state.queueLength = Math.max(0, state.queueLength + inflow - processed); state.entriesPerMinute = processed; this.inside = Math.min(this.inside + processed, EXPECTED_ATTENDANCE); } @@ -86,7 +87,7 @@ export class SimulationEngine { atMinute: this.minute, gateId: gate.id, entriesPerMinute: state.entriesPerMinute, - queueLength: state.queue, + queueLength: state.queueLength, }); } } @@ -116,18 +117,21 @@ export class SimulationEngine { private applyScripted(event: StadiumEvent): void { switch (event.type) { case "match": - this.phase = event.phase === "goal" ? this.phase : event.phase; + this.phase = nextPhase(this.phase, event); break; case "gate-flow": { const state = this.gates.get(event.gateId); if (state) { - state.queue = event.queueLength; + state.queueLength = event.queueLength; state.entriesPerMinute = event.entriesPerMinute; } break; } case "crowd-density": - this.densityOverrides.set(event.zoneId, { pct: event.densityPct, until: this.minute + 12 }); + this.densityOverrides.set(event.zoneId, { + pct: event.densityPct, + until: this.minute + DENSITY_OVERRIDE_DECAY_MIN, + }); break; default: break; @@ -160,7 +164,7 @@ export class SimulationEngine { const zone = DEMO_VENUE.zones.find((z) => z.id === zoneId); if (!zone) return 0; if (zone.kind === "gate") { - const queued = zone.gateIds.reduce((sum, id) => sum + (this.gates.get(id)?.queue ?? 0), 0); + const queued = zone.gateIds.reduce((sum, id) => sum + (this.gates.get(id)?.queueLength ?? 0), 0); return queued + Math.round(this.rng() * 200); } const concourseShare = this.phase === "halftime" ? 0.42 : this.phase === "gates-open" ? 0.3 : 0.1; diff --git a/src/lib/simulator/live.ts b/src/lib/simulator/live.ts index 41f4abb..36df6bc 100644 --- a/src/lib/simulator/live.ts +++ b/src/lib/simulator/live.ts @@ -1,4 +1,4 @@ -import { SIM_TICK_MS } from "@/shared/constants"; +import { CATCHUP_EVENT_COUNT, MAX_TICK_MS, MIN_TICK_MS, SIM_TICK_MS } from "@/shared/constants"; import type { Incident, StadiumEvent, StreamMessage } from "@/shared/models"; import { IncidentDetector } from "./detector"; import { SimulationEngine } from "./engine"; @@ -39,7 +39,7 @@ class LiveMatch { subscribe(send: Send): () => void { // Catch-up burst so a late joiner rebuilds the same picture. - for (const event of this.eventLog.slice(-250)) send({ kind: "event", event }); + for (const event of this.eventLog.slice(-CATCHUP_EVENT_COUNT)) send({ kind: "event", event }); for (const incident of this.incidents) send({ kind: "incident", incident }); send({ kind: "clock", minute: this.engine.clockMinute }); this.subscribers.add(send); @@ -53,7 +53,7 @@ class LiveMatch { control(action: SimControl): void { switch (action.type) { case "speed": { - this.tickMs = Math.min(Math.max(action.tickMs, 250), 10000); + this.tickMs = Math.min(Math.max(action.tickMs, MIN_TICK_MS), MAX_TICK_MS); this.pause(); this.ensureTicking(); break; diff --git a/src/lib/useMatchStream.test.ts b/src/lib/useMatchStream.test.ts new file mode 100644 index 0000000..073c304 --- /dev/null +++ b/src/lib/useMatchStream.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { dashReducer, insideEstimate, worstDensityPct, type DashState } from "./useMatchStream"; +import type { CrowdDensityEvent, GateFlowEvent, Incident, MatchEvent } from "@/shared/models"; + +const INITIAL = dashReducer({} as DashState, { type: "reset" }); + +const density = (id: string, zoneId: string, densityPct: number, occupancy = 0): CrowdDensityEvent => ({ + type: "crowd-density", + id, + venueId: "meridian-arena", + atMinute: 10, + zoneId, + occupancy, + densityPct, +}); + +const gateFlow = (id: string, gateId: string, queueLength: number): GateFlowEvent => ({ + type: "gate-flow", + id, + venueId: "meridian-arena", + atMinute: 10, + gateId, + entriesPerMinute: 120, + queueLength, +}); + +const incident = (id: string): Incident => ({ + id, + createdAtMinute: 10, + title: "Crowd density", + category: "crowd", + severity: "medium", + status: "open", + sourceEventIds: [], +}); + +describe("dashReducer", () => { + it("resets to the initial state", () => { + const dirty: DashState = { ...INITIAL, minute: 99, events: [density("e1", "z", 80)] }; + expect(dashReducer(dirty, { type: "reset" })).toEqual(INITIAL); + expect(dashReducer(dirty, { type: "message", msg: { kind: "reset" } })).toEqual(INITIAL); + }); + + it("advances the clock", () => { + const s = dashReducer(INITIAL, { type: "message", msg: { kind: "clock", minute: 42 } }); + expect(s.minute).toBe(42); + }); + + it("projects a crowd-density event into the zones map", () => { + const s = dashReducer(INITIAL, { + type: "message", + msg: { kind: "event", event: density("e1", "concourse-north", 77, 6900) }, + }); + expect(s.zones["concourse-north"]).toEqual({ occupancy: 6900, densityPct: 77 }); + expect(s.events).toHaveLength(1); + }); + + it("projects a gate-flow event into the gates map", () => { + const s = dashReducer(INITIAL, { + type: "message", + msg: { kind: "event", event: gateFlow("e1", "gate-a", 310) }, + }); + expect(s.gates["gate-a"]).toEqual({ entriesPerMinute: 120, queueLength: 310 }); + }); + + it("dedupes events by id (catch-up replay is idempotent)", () => { + const once = dashReducer(INITIAL, { + type: "message", + msg: { kind: "event", event: density("dup", "seating-bowl", 50) }, + }); + const twice = dashReducer(once, { + type: "message", + msg: { kind: "event", event: density("dup", "seating-bowl", 50) }, + }); + expect(twice.events).toHaveLength(1); + }); + + it("keeps the phase on a goal but advances it on a real transition", () => { + const match = (phase: MatchEvent["phase"]): MatchEvent => ({ + type: "match", + id: `m-${phase}`, + venueId: "meridian-arena", + atMinute: 60, + phase, + }); + const kicked = dashReducer(INITIAL, { type: "message", msg: { kind: "event", event: match("kickoff") } }); + expect(kicked.phase).toBe("kickoff"); + const afterGoal = dashReducer(kicked, { type: "message", msg: { kind: "event", event: match("goal") } }); + expect(afterGoal.phase).toBe("kickoff"); + }); + + it("prepends incidents and dedupes them by id", () => { + const one = dashReducer(INITIAL, { type: "message", msg: { kind: "incident", incident: incident("i1") } }); + const two = dashReducer(one, { type: "message", msg: { kind: "incident", incident: incident("i2") } }); + expect(two.incidents.map((i) => i.id)).toEqual(["i2", "i1"]); + const dup = dashReducer(two, { type: "message", msg: { kind: "incident", incident: incident("i2") } }); + expect(dup.incidents).toHaveLength(2); + }); + + it("applies triage to the matching incident and lifts its severity", () => { + const withIncident = dashReducer(INITIAL, { + type: "message", + msg: { kind: "incident", incident: incident("i1") }, + }); + const s = dashReducer(withIncident, { + type: "triage", + incidentId: "i1", + triage: { + summary: "s", + severity: "high", + rationale: "r", + recommendedActions: [], + escalate: false, + }, + }); + expect(s.incidents[0].severity).toBe("high"); + expect(s.incidents[0].triage?.summary).toBe("s"); + }); + + it("updates an incident's status", () => { + const withIncident = dashReducer(INITIAL, { + type: "message", + msg: { kind: "incident", incident: incident("i1") }, + }); + const s = dashReducer(withIncident, { type: "status", incidentId: "i1", status: "resolved" }); + expect(s.incidents[0].status).toBe("resolved"); + }); +}); + +describe("worstDensityPct", () => { + it("returns 0 with no telemetry", () => { + expect(worstDensityPct({})).toBe(0); + }); + + it("returns the highest zone density", () => { + expect( + worstDensityPct({ + a: { occupancy: 0, densityPct: 70 }, + b: { occupancy: 0, densityPct: 88 }, + }), + ).toBe(88); + }); +}); + +describe("insideEstimate", () => { + it("sums seating + concourse occupancy and ignores gate plazas", () => { + const inside = insideEstimate({ + "concourse-north": { occupancy: 100, densityPct: 0 }, + "concourse-south": { occupancy: 150, densityPct: 0 }, + "seating-bowl": { occupancy: 200, densityPct: 0 }, + "gate-plaza-north": { occupancy: 999, densityPct: 0 }, + }); + expect(inside).toBe(450); + }); +}); diff --git a/src/lib/useMatchStream.ts b/src/lib/useMatchStream.ts index 9990269..4e81cba 100644 --- a/src/lib/useMatchStream.ts +++ b/src/lib/useMatchStream.ts @@ -1,7 +1,15 @@ "use client"; -import { useEffect, useReducer } from "react"; -import type { Incident, MatchPhase, StadiumEvent, StreamMessage, TriageResult } from "@/shared/models"; +import { useEffect, useReducer, useState } from "react"; +import { CATCHUP_EVENT_COUNT, DEMO_VENUE } from "@/shared/constants"; +import { + nextPhase, + type Incident, + type MatchPhase, + type StadiumEvent, + type StreamMessage, + type TriageResult, +} from "@/shared/models"; /** Accumulated match-day state, rebuilt on the client from the shared SSE feed. * Used by the ops, staff, and tournament views via the useMatchStream hook. */ @@ -61,8 +69,8 @@ export function dashReducer(state: DashState, action: DashAction): DashState { if (msg.kind === "event") { const e = msg.event; if (state.events.some((x) => x.id === e.id)) return state; - const next: DashState = { ...state, events: [e, ...state.events].slice(0, 250) }; - if (e.type === "match") next.phase = e.phase === "goal" ? state.phase : e.phase; + const next: DashState = { ...state, events: [e, ...state.events].slice(0, CATCHUP_EVENT_COUNT) }; + if (e.type === "match") next.phase = nextPhase(state.phase, e); if (e.type === "crowd-density") { next.zones = { ...state.zones, [e.zoneId]: { occupancy: e.occupancy, densityPct: e.densityPct } }; } @@ -86,7 +94,7 @@ export function dashReducer(state: DashState, action: DashAction): DashState { */ export function useMatchStream() { const [state, dispatch] = useReducer(dashReducer, INITIAL); - const [connected, setConnected] = useReducerConnected(); + const [connected, setConnected] = useState(false); useEffect(() => { const source = new EventSource("/api/stream"); @@ -108,7 +116,16 @@ export function useMatchStream() { return { state, dispatch, connected }; } -/** Tiny boolean reducer kept separate so the connection flag doesn't churn state. */ -function useReducerConnected() { - return useReducer((_: boolean, v: boolean) => v, false) as [boolean, (v: boolean) => void]; +/** Highest zone density across the live state (0 before any telemetry). Shared + * by the ops and tournament views so they never disagree on "worst zone". */ +export function worstDensityPct(zones: DashState["zones"]): number { + return Math.max(0, ...Object.values(zones).map((z) => z.densityPct)); +} + +/** Estimated fans inside = summed occupancy of seating + concourse zones. + * Keyed on the typed `Venue` model, not zone-id string matching. */ +export function insideEstimate(zones: DashState["zones"]): number { + return DEMO_VENUE.zones + .filter((z) => z.kind === "seating" || z.kind === "concourse") + .reduce((sum, z) => sum + (zones[z.id]?.occupancy ?? 0), 0); } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 204af98..49c3a6e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -14,8 +14,17 @@ export const DENSITY_CRITICAL_PCT = 92; /** Gate queue length at which a gate incident is auto-opened. */ export const QUEUE_ALERT_LENGTH = 300; -/** Simulation pacing: one simulated minute per real tick. */ +/** Simulation pacing: one simulated minute per real tick (the "1×" speed). */ export const SIM_TICK_MS = 2000; +/** The "4×" fast-forward pacing offered in the sim-speed control. */ +export const SIM_TICK_FAST_MS = 500; +/** Bounds the speed control clamps tick pacing to (ms per simulated minute). */ +export const MIN_TICK_MS = 250; +export const MAX_TICK_MS = 10000; +/** Recent events replayed to a late joiner as a catch-up burst, and the number + * the client keeps in memory. Kept ≤ the server's event-log cap so the burst + * never asks for more history than is retained. */ +export const CATCHUP_EVENT_COUNT = 250; /** Kickoff happens this many simulated minutes after gates open. */ export const KICKOFF_MINUTE = 60; /** Fans expected through the gates for the demo match (~88% of capacity). */ diff --git a/src/shared/models/demand.ts b/src/shared/models/demand.ts index f588815..dcabfcd 100644 --- a/src/shared/models/demand.ts +++ b/src/shared/models/demand.ts @@ -13,6 +13,22 @@ export type SupplyItem = | "ice" | "energy-drink"; +/** Every supply item, in display order. The demand route derives both its + * response-schema enum and its default-stock table from this one list. */ +export const SUPPLY_ITEMS = [ + "umbrella", + "poncho", + "bottled-water", + "cold-drink", + "hot-beverage", + "hot-food", + "cold-food", + "handheld-fan", + "blanket", + "ice", + "energy-drink", +] as const satisfies readonly SupplyItem[]; + export type DemandUrgency = "now" | "before-kickoff" | "by-halftime" | "monitor"; export interface DemandLine { diff --git a/src/shared/models/events.test.ts b/src/shared/models/events.test.ts new file mode 100644 index 0000000..ac40dfe --- /dev/null +++ b/src/shared/models/events.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { nextPhase, type MatchEvent } from "./index"; + +const matchEvent = (phase: MatchEvent["phase"]): MatchEvent => ({ + type: "match", + id: "m1", + venueId: "meridian-arena", + atMinute: 60, + phase, +}); + +describe("nextPhase", () => { + it("adopts the event phase for real transitions", () => { + expect(nextPhase("gates-open", matchEvent("kickoff"))).toBe("kickoff"); + expect(nextPhase("kickoff", matchEvent("halftime"))).toBe("halftime"); + expect(nextPhase("halftime", matchEvent("second-half"))).toBe("second-half"); + expect(nextPhase("second-half", matchEvent("fulltime"))).toBe("fulltime"); + }); + + it("keeps the running phase for a transient goal event", () => { + expect(nextPhase("kickoff", matchEvent("goal"))).toBe("kickoff"); + expect(nextPhase("second-half", matchEvent("goal"))).toBe("second-half"); + }); +}); diff --git a/src/shared/models/events.ts b/src/shared/models/events.ts index 75edef1..c0906b7 100644 --- a/src/shared/models/events.ts +++ b/src/shared/models/events.ts @@ -64,6 +64,16 @@ export interface MatchEvent extends BaseEvent { note?: string; } +/** + * Fold a match event into the running phase. `goal` is modeled as a transient + * `MatchPhase` but must not overwrite the real phase (kickoff/second-half/…), + * so it's held here in one place — the sim engine and the client reducer both + * call this instead of re-deriving the rule. + */ +export function nextPhase(current: MatchPhase, event: MatchEvent): MatchPhase { + return event.phase === "goal" ? current : event.phase; +} + export type StadiumEvent = | GateFlowEvent | CrowdDensityEvent diff --git a/src/shared/models/staff.test.ts b/src/shared/models/staff.test.ts new file mode 100644 index 0000000..079216a --- /dev/null +++ b/src/shared/models/staff.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { clampPriority, STAFF_ROLES } from "./index"; + +describe("clampPriority", () => { + it("passes in-range integers through", () => { + expect(clampPriority(1)).toBe(1); + expect(clampPriority(2)).toBe(2); + expect(clampPriority(3)).toBe(3); + }); + + it("clamps out-of-range numbers into the 1..3 band", () => { + expect(clampPriority(0)).toBe(1); + expect(clampPriority(-4)).toBe(1); + expect(clampPriority(5)).toBe(3); + }); + + it("rounds fractional values", () => { + expect(clampPriority(2.4)).toBe(2); + expect(clampPriority(2.6)).toBe(3); + }); + + it("falls back to 2 on non-finite input", () => { + expect(clampPriority(NaN)).toBe(2); + expect(clampPriority(Infinity)).toBe(2); + expect(clampPriority(-Infinity)).toBe(2); + }); +}); + +describe("STAFF_ROLES", () => { + it("lists all eight roles with no duplicates", () => { + expect(STAFF_ROLES).toHaveLength(8); + expect(new Set(STAFF_ROLES).size).toBe(8); + expect(STAFF_ROLES).toContain("stewarding"); + expect(STAFF_ROLES).toContain("logistics"); + }); +}); diff --git a/src/shared/models/staff.ts b/src/shared/models/staff.ts index 3332a94..891308c 100644 --- a/src/shared/models/staff.ts +++ b/src/shared/models/staff.ts @@ -10,6 +10,27 @@ export type StaffRole = | "traffic" | "logistics"; +/** Every staff role as a runtime list. Routes that offer roles to the model + * (demand, emergency) and validate an incoming role (tasks) share this one + * source so a new role can't silently drift across files. */ +export const STAFF_ROLES = [ + "catering", + "concessions", + "stewarding", + "security", + "medical", + "facilities", + "traffic", + "logistics", +] as const satisfies readonly StaffRole[]; + +/** Clamp any (possibly bad) number into the 1..3 priority band used by tasks, + * recommended actions, and evacuation orders. Non-finite input falls back to 2. */ +export function clampPriority(n: number): 1 | 2 | 3 { + if (!Number.isFinite(n)) return 2; + return Math.min(3, Math.max(1, Math.round(n))) as 1 | 2 | 3; +} + export type TaskStatus = "pending" | "acked" | "in-progress" | "done"; /** What generated the task — lets the staff board group by cause. */ diff --git a/src/shared/models/stream.ts b/src/shared/models/stream.ts index c190426..b7de9d1 100644 --- a/src/shared/models/stream.ts +++ b/src/shared/models/stream.ts @@ -7,6 +7,10 @@ import type { Venue } from "./venue"; * is one JSON-encoded StreamMessage. The dashboard switches on `kind`. */ +/** Reserved: a full-state snapshot variant. Not emitted yet — the server only + * sends incremental clock/event/incident frames plus a catch-up replay, so + * `dashReducer` deliberately has no `snapshot` branch. Kept in the protocol so + * the wire format can add it without a breaking change. */ export interface OpsSnapshot { minute: number; venue: Venue; @@ -20,6 +24,7 @@ export type StreamMessage = | { kind: "clock"; minute: number } | { kind: "event"; event: StadiumEvent } | { kind: "incident"; incident: Incident } + /** Reserved — see OpsSnapshot; not currently emitted. */ | { kind: "snapshot"; state: OpsSnapshot } /** The shared match was restarted — clients must drop accumulated state. */ | { kind: "reset" };