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" };