From 1d265e94c5f92ec9a276ad16199d4bafbcb8242e Mon Sep 17 00:00:00 2001 From: Sarthak195 Date: Wed, 15 Jul 2026 22:12:29 +0530 Subject: [PATCH] Final polish: security, accessibility, tests, code quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A four-part pass across the movable score dimensions, each verified. Security - In-memory per-client rate limiter (src/lib/rate-limit.ts) on all 7 AI/ mutating routes -> 429 + Retry-After; guards Gemini quota + shared demo. - SECURITY.md documenting the posture (headers, validation, rate limits, constrained AI actions, secret handling, injection-sink review). Accessibility (verified 0 violations) - Playwright + axe-core audit of the 3 pages plus interactive states (copilot, emergency dialog); `npm run audit:a11y`. - Fixed the 3 real violations it found: Tag + critical-red text contrast (added CRITICAL_TEXT token for text-on-dark), and a keyboard-focusable scroll region for the live feed. Testing (coverage 43% -> ~77%) - API route validation + rate-limit tests, SimulationEngine determinism tests, rate-limiter unit tests. 69 -> 85 tests. - Coverage floor enforced in vitest config (lines/statements 70%). Code quality - Typed the Google Maps integration via a minimal local ambient surface (src/types/google-maps.d.ts) — removes the last `any` cluster. - Decomposed dashboard.tsx: extracted EventLine/IncidentCard/DocOverlay into dashboard-parts.tsx (~200 lines out of the god component). - Prettier added (config + format/format:check scripts) and applied repo-wide; ESLint node-globals for scripts. CI now runs format:check + lint + typecheck + tests (with coverage floor) + build. Verified: format/lint/typecheck clean, 85 tests, 0 axe violations, production build succeeds. --- .github/workflows/ci.yml | 1 + .prettierignore | 6 + .prettierrc.json | 6 + CLAUDE.md | 2 +- README.md | 15 +- SECURITY.md | 21 ++ eslint.config.mjs | 6 + package-lock.json | 109 ++++++- package.json | 7 + scripts/a11y-audit.mjs | 53 ++++ src/app/api/briefing/route.ts | 8 +- src/app/api/copilot/route.ts | 9 +- src/app/api/demand/route.ts | 10 +- src/app/api/emergency/route.ts | 4 + src/app/api/routes.test.ts | 57 ++++ src/app/api/sim/route.ts | 4 + src/app/api/tasks/route.ts | 4 + src/app/api/triage/route.ts | 4 + src/components/dashboard-parts.tsx | 242 +++++++++++++++ src/components/dashboard.tsx | 375 +++++++----------------- src/components/modal.tsx | 3 +- src/components/primitives.tsx | 16 +- src/components/staff-dashboard.tsx | 66 +++-- src/components/tournament-dashboard.tsx | 41 ++- src/components/venue-map.tsx | 58 ++-- src/components/voice-radio.tsx | 11 +- src/lib/gemini.test.ts | 3 +- src/lib/predict.test.ts | 74 ++++- src/lib/rate-limit.test.ts | 42 +++ src/lib/rate-limit.ts | 80 +++++ src/lib/simulator/detector.test.ts | 18 +- src/lib/simulator/engine.test.ts | 39 +++ src/lib/simulator/engine.ts | 8 +- src/lib/simulator/live.ts | 4 +- src/lib/simulator/scenario.ts | 25 +- src/lib/theme.ts | 14 +- src/lib/tournament.ts | 48 ++- src/lib/traffic/model.test.ts | 8 +- src/lib/traffic/model.ts | 42 ++- src/lib/useMatchStream.test.ts | 32 +- src/lib/useMatchStream.ts | 10 +- src/lib/weather/client.ts | 17 +- src/shared/constants.ts | 32 +- src/shared/models/events.ts | 14 +- src/shared/models/incident.ts | 7 +- src/shared/models/staff.ts | 8 +- src/shared/models/venue.ts | 8 +- src/shared/models/weather.ts | 8 +- src/types/google-maps.d.ts | 67 +++++ vitest.config.ts | 7 + 50 files changed, 1318 insertions(+), 435 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 SECURITY.md create mode 100644 scripts/a11y-audit.mjs create mode 100644 src/app/api/routes.test.ts create mode 100644 src/components/dashboard-parts.tsx create mode 100644 src/lib/rate-limit.test.ts create mode 100644 src/lib/rate-limit.ts create mode 100644 src/lib/simulator/engine.test.ts create mode 100644 src/types/google-maps.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc873b6..2c35354 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm run format:check - run: npm run lint - run: npm run typecheck - run: npm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3a48c6b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +.next +node_modules +coverage +package-lock.json +*.md +docs diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..90abee2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/CLAUDE.md b/CLAUDE.md index 4f16f2b..5afeb9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ AI incident command copilot for stadium/tournament operations. Solo entry for ** - `/api/emergency` — Gemini evacuation plan (PA announcement + command summary + zone orders) AND `[EVAC]` staff tasks added server-side to the store (origin "emergency"). Ops UI: guarded red button → confirm → banner + plan overlay; gates render EGRESS. - `src/components/dashboard.tsx` — ops control room UI (`/`). `src/components/staff-dashboard.tsx` — staff console (`/staff`), a second persona: weather + AI demand plan + role-grouped task board. Both share `src/lib/useMatchStream.ts` (SSE→state hook), `src/lib/theme.ts` (CVD/contrast-validated dark tokens — status colors always paired with a text label), and `src/components/primitives.tsx` (Panel/Tag/StatRow). - `src/lib/weather/client.ts` — live weather via Open-Meteo (keyless) with a deterministic simulated fallback; `/api/weather` exposes it. `src/lib/traffic/model.ts` — deterministic approach-traffic/parking model (`trafficAt(minute, phase)`), computed client-side; geo fixtures pinned near `VENUE_LOCATION` (Indore). -- `src/components/venue-map.tsx` — Google Maps + live TrafficLayer when `MAPS_API_KEY` is set (fetched at runtime from `/api/config`, not build-inlined), SVG schematic fallback otherwise. Google objects are locally `any` (no `@types/google.maps` dep). +- `src/components/venue-map.tsx` — Google Maps + live TrafficLayer when `MAPS_API_KEY` is set (fetched at runtime from `/api/config`, not build-inlined), SVG schematic fallback otherwise. Google Maps is typed via a minimal local ambient surface (`src/types/google-maps.d.ts`), avoiding the full `@types/google.maps` dep. - `/api/demand` — Gemini demand forecast: weather + attendance + phase → stocking plan (`DemandPlan`) + prep `StaffTask`s. `/api/config` — runtime bootstrap (maps key presence). All AI routes stay stateless; the client posts accumulated state. Gemini errors go through `geminiErrorMessage()` for UI-friendly text. - `/api/copilot` — agentic chat: history + snapshot in; text and/or `toolCalls` out (open_incident / dispatch_task / generate_briefing). Tool calls execute CLIENT-side in `dashboard.tsx#runToolCall`; the system prompt enforces "act only on instruction, answer questions with text". - `/api/tasks` (GET/POST) + `src/lib/store.ts` — in-memory dispatch queue; staff console polls every 15s. Single-instance by design. diff --git a/README.md b/README.md index ff2a720..7106ad5 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ cp .env.example .env.local # add your Gemini API key (free at aistudio.google. npm run dev # http://localhost:3000 ``` -Useful checks: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`. +Useful checks: `npm run lint`, `npm run format`, `npm run typecheck`, `npm test`, `npm run build`. ## Testing @@ -79,13 +79,22 @@ Unit tests ([Vitest](https://vitest.dev)) cover the deterministic core — the l - **Client state** — the SSE reducer's dedupe / reset / zone-gate projection logic - **Dispatch store** — id assignment, status defaults, queue cap +- **Routes & security** — API request-body validation (400s) and per-client rate limiting (429s) + ```bash npm test # run once npm run test:watch # watch mode -npm run test:coverage +npm run test:coverage # coverage floor enforced (~77%) +``` + +**Accessibility** is audited with Playwright + [axe-core](https://github.com/dequelabs/axe-core) against the running app — static pages plus interactive states (copilot, modals) — and passes with **zero WCAG 2.1 AA violations**: + +```bash +npm run dev # in one terminal +npm run audit:a11y # in another ``` -CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs lint, typecheck, the full test suite, and a production build on every push and pull request. +CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs Prettier check, ESLint, typecheck, the full test suite (with coverage floor), and a production build on every push and pull request. Security posture is documented in [SECURITY.md](SECURITY.md). ## Deploy to Cloud Run diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a576852 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security + +MatchDay Command is a demonstration app for the Google PromptWars competition. All match-day data is **simulated** — there is no real user data, no authentication, and no personal information by design. Still, the app follows sensible web-security practice. + +## Reporting a vulnerability + +Open a private security advisory on the GitHub repository, or email the maintainer. Please do not file public issues for security reports. + +## Measures in place + +- **Security headers** (`next.config.ts`): `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`, and a production `Content-Security-Policy`. +- **Input validation**: every API route validates its request body and returns a `400` on malformed input rather than throwing; free-text fields are length-bounded. +- **Rate limiting** (`src/lib/rate-limit.ts`): the AI and mutating routes are rate-limited per client to prevent a single caller from exhausting Gemini quota or disrupting the shared demo (`429` with `Retry-After`). +- **Constrained AI actions**: the copilot can only invoke a fixed allowlist of tools (open incident, dispatch task, generate briefing), executed client-side through an exhaustive `switch` — the model cannot induce arbitrary actions. +- **Secret handling**: `GEMINI_API_KEY` is read server-side only and never sent to the client. The Google Maps browser key is delivered at runtime and must be HTTP-referrer + API restricted. +- **No injection sinks**: no `dangerouslySetInnerHTML`, `eval`, or `new Function`; React escapes all rendered content. The only outbound `fetch` targets fixed, non-user-controlled hosts (Gemini, Open-Meteo). + +## Scope notes + +- The app is deployed as a **single Cloud Run instance** (`--max-instances 1`); match-day state and rate-limit windows are in-memory and reset on restart, which is intentional for the demo. +- Simulation controls (`/api/sim`) are intentionally global and unauthenticated — the demo is a shared control room, not a per-viewer session. diff --git a/eslint.config.mjs b/eslint.config.mjs index d9ff249..a114a1c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,7 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; export default tseslint.config( { ignores: [".next/**", "node_modules/**", "coverage/**", "next-env.d.ts"] }, @@ -14,4 +15,9 @@ export default tseslint.config( "react-hooks/exhaustive-deps": "warn", }, }, + { + // Node scripts / config files (plain JS) — provide Node globals. + files: ["scripts/**", "*.{js,mjs,cjs}"], + languageOptions: { globals: globals.node }, + }, ); diff --git a/package-lock.json b/package-lock.json index c41dbef..6268b74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^9.39.5", "@tailwindcss/postcss": "^4.0.0", "@types/node": "^22", @@ -22,6 +23,9 @@ "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.7.0", + "playwright": "^1.61.1", + "prettier": "^3.9.5", "tailwindcss": "^4.0.0", "typescript": "^5", "typescript-eslint": "^8.64.0", @@ -41,6 +45,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -458,6 +475,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", @@ -2571,6 +2601,16 @@ "js-tokens": "^10.0.0" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3285,9 +3325,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -4282,6 +4322,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -4321,6 +4408,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/protobufjs": { "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", diff --git a/package.json b/package.json index 50fa1d8..b704c60 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", "typecheck": "tsc --noEmit", + "audit:a11y": "node scripts/a11y-audit.mjs", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -20,6 +23,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^9.39.5", "@tailwindcss/postcss": "^4.0.0", "@types/node": "^22", @@ -28,6 +32,9 @@ "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.7.0", + "playwright": "^1.61.1", + "prettier": "^3.9.5", "tailwindcss": "^4.0.0", "typescript": "^5", "typescript-eslint": "^8.64.0", diff --git a/scripts/a11y-audit.mjs b/scripts/a11y-audit.mjs new file mode 100644 index 0000000..146bbec --- /dev/null +++ b/scripts/a11y-audit.mjs @@ -0,0 +1,53 @@ +import { chromium } from "playwright"; +import AxeBuilder from "@axe-core/playwright"; + +const BASE = process.env.BASE_URL || "http://localhost:3000"; +const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]; + +const browser = await chromium.launch(); +const context = await browser.newContext(); +let total = 0; + +function report(label, violations) { + total += violations.length; + console.log(`\n=== ${label} — ${violations.length} violation(s) ===`); + for (const v of violations) { + console.log(`[${v.impact}] ${v.id}: ${v.help}`); + for (const n of v.nodes.slice(0, 6)) console.log(` ${n.target.join(" ")}`); + } +} + +async function audit(page, label) { + const { violations } = await new AxeBuilder({ page }).withTags(TAGS).analyze(); + report(label, violations); +} + +// Static pages +for (const path of ["/", "/staff", "/tournament"]) { + const page = await context.newPage(); + await page.goto(BASE + path, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(2000); + await audit(page, path); + await page.close(); +} + +// Interactive states on the ops page (modals + copilot are where a11y hides) +const page = await context.newPage(); +await page.goto(BASE + "/", { waitUntil: "domcontentloaded", timeout: 30000 }); +await page.waitForTimeout(1500); + +await page.getByRole("button", { name: /copilot/i }).click(); +await page.waitForTimeout(400); +await audit(page, "/ (copilot open)"); +await page + .getByRole("button", { name: /close copilot/i }) + .click() + .catch(() => {}); + +await page.getByRole("button", { name: /emergency/i }).click(); +await page.waitForTimeout(400); +await audit(page, "/ (emergency confirm dialog)"); + +await browser.close(); +console.log(`\nTOTAL violations: ${total}`); +process.exit(total > 0 ? 1 : 0); diff --git a/src/app/api/briefing/route.ts b/src/app/api/briefing/route.ts index 5564e6b..9373899 100644 --- a/src/app/api/briefing/route.ts +++ b/src/app/api/briefing/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { Type } from "@google/genai"; import { geminiErrorMessage, generateJson } from "@/lib/gemini"; +import { rateLimitResponse } from "@/lib/rate-limit"; import { DEMO_VENUE } from "@/shared/constants"; import type { HandoverReport, Incident, OpsBriefing, StadiumEvent } from "@/shared/models"; @@ -54,6 +55,9 @@ function contextBlock(body: BriefingRequest): string { * The client sends its accumulated state; the server stays stateless. */ export async function POST(req: NextRequest) { + const limited = rateLimitResponse(req, "briefing", { limit: 30, windowMs: 60_000 }); + if (limited) return limited; + let body: BriefingRequest; try { body = (await req.json()) as BriefingRequest; @@ -68,7 +72,9 @@ export async function POST(req: NextRequest) { try { if (isHandover) { - const generated = await generateJson>({ + const generated = await generateJson< + Pick + >({ system: SYSTEM, prompt: `Write the shift-handover report for "${body.shiftLabel ?? "current shift"}". Summarize what happened, what was decided and why, and what the incoming shift must pick up.\n\n${contextBlock(body)}`, schema: HANDOVER_SCHEMA, diff --git a/src/app/api/copilot/route.ts b/src/app/api/copilot/route.ts index 7b97e56..37cdebc 100644 --- a/src/app/api/copilot/route.ts +++ b/src/app/api/copilot/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { Type, type FunctionDeclaration } from "@google/genai"; import { geminiErrorMessage, getClient, getModel } from "@/lib/gemini"; +import { rateLimitResponse } from "@/lib/rate-limit"; import { DEMO_VENUE } from "@/shared/constants"; import { STAFF_ROLES } from "@/shared/models"; @@ -62,7 +63,10 @@ const TOOL_DECLARATIONS: FunctionDeclaration[] = [ title: { type: Type.STRING }, detail: { type: Type.STRING, description: "Specific, actionable instruction" }, priority: { type: Type.INTEGER, description: "1 = do now, 2 = soon, 3 = when able" }, - dueBy: { type: Type.STRING, description: 'e.g. "before kickoff", "by halftime" (optional)' }, + dueBy: { + type: Type.STRING, + description: 'e.g. "before kickoff", "by halftime" (optional)', + }, }, required: ["role", "title", "detail", "priority"], }, @@ -88,6 +92,9 @@ ${JSON.stringify(snapshot)}`; } export async function POST(req: NextRequest) { + const limited = rateLimitResponse(req, "copilot", { limit: 40, windowMs: 60_000 }); + if (limited) return limited; + let body: CopilotRequest; try { body = (await req.json()) as CopilotRequest; diff --git a/src/app/api/demand/route.ts b/src/app/api/demand/route.ts index 8353c32..34f7582 100644 --- a/src/app/api/demand/route.ts +++ b/src/app/api/demand/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { Type } from "@google/genai"; import { geminiErrorMessage, generateJson } from "@/lib/gemini"; +import { rateLimitResponse } from "@/lib/rate-limit"; import { DEMO_VENUE } from "@/shared/constants"; import { clampPriority, @@ -86,6 +87,9 @@ interface GeneratedDemand { /** POST weather + attendance + phase; returns a DemandPlan and prep StaffTasks. */ export async function POST(req: NextRequest) { + const limited = rateLimitResponse(req, "demand", { limit: 20, windowMs: 60_000 }); + if (limited) return limited; + let body: DemandRequest; try { body = (await req.json()) as DemandRequest; @@ -111,7 +115,11 @@ export async function POST(req: NextRequest) { ].join("\n"); try { - const gen = await generateJson({ system: SYSTEM, prompt, schema: DEMAND_SCHEMA }); + const gen = await generateJson({ + system: SYSTEM, + prompt, + schema: DEMAND_SCHEMA, + }); const lines: DemandLine[] = gen.lines.map((l) => ({ ...l, diff --git a/src/app/api/emergency/route.ts b/src/app/api/emergency/route.ts index cecedf8..138bb84 100644 --- a/src/app/api/emergency/route.ts +++ b/src/app/api/emergency/route.ts @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { Type } from "@google/genai"; import { geminiErrorMessage, generateJson } from "@/lib/gemini"; import { addTask } from "@/lib/store"; +import { rateLimitResponse } from "@/lib/rate-limit"; import { DEMO_VENUE } from "@/shared/constants"; import { clampPriority, @@ -80,6 +81,9 @@ interface GeneratedEvac { } export async function POST(req: NextRequest) { + const limited = rateLimitResponse(req, "emergency", { limit: 10, windowMs: 60_000 }); + if (limited) return limited; + let body: EmergencyRequest; try { body = (await req.json()) as EmergencyRequest; diff --git a/src/app/api/routes.test.ts b/src/app/api/routes.test.ts new file mode 100644 index 0000000..2b9220d --- /dev/null +++ b/src/app/api/routes.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { NextRequest } from "next/server"; +import { __resetRateLimits } from "@/lib/rate-limit"; +import { POST as triage } from "./triage/route"; +import { POST as demand } from "./demand/route"; +import { POST as sim } from "./sim/route"; +import { POST as tasks } from "./tasks/route"; + +beforeEach(() => __resetRateLimits()); + +/** Build a POST NextRequest; distinct `ip` isolates the rate-limiter per test. */ +function post(path: string, body: unknown, ip: string): NextRequest { + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": ip }, + body: typeof body === "string" ? body : JSON.stringify(body), + }) as unknown as NextRequest; +} + +describe("API route validation (short-circuits before any Gemini call)", () => { + it("triage → 400 on invalid JSON", async () => { + expect((await triage(post("/api/triage", "{not json", "v1"))).status).toBe(400); + }); + + it("demand → 400 when attendance is missing", async () => { + const res = await demand( + post("/api/demand", { minute: 10, phase: "kickoff", weather: {} }, "v2"), + ); + expect(res.status).toBe(400); + }); + + it("sim → 400 on a non-finite tickMs", async () => { + expect((await sim(post("/api/sim", { type: "speed" }, "v3"))).status).toBe(400); + }); + + it("sim → 400 on an unknown control type", async () => { + expect((await sim(post("/api/sim", { type: "bogus" }, "v4"))).status).toBe(400); + }); + + it("tasks → 400 on an invalid role", async () => { + const res = await tasks(post("/api/tasks", { role: "wizard", title: "t", detail: "d" }, "v5")); + expect(res.status).toBe(400); + }); +}); + +describe("API rate limiting", () => { + it("returns 429 once a client exceeds the per-route limit", async () => { + const ip = "flooder"; + let status = 0; + // demand allows 20/min; the limiter runs before body parsing, so even + // malformed requests count. The 21st call must be blocked. + for (let i = 0; i < 21; i++) { + status = (await demand(post("/api/demand", {}, ip))).status; + } + expect(status).toBe(429); + }); +}); diff --git a/src/app/api/sim/route.ts b/src/app/api/sim/route.ts index c737cae..6ed6c93 100644 --- a/src/app/api/sim/route.ts +++ b/src/app/api/sim/route.ts @@ -1,10 +1,14 @@ import { NextResponse, type NextRequest } from "next/server"; import { getLiveMatch, type SimControl } from "@/lib/simulator/live"; +import { rateLimitResponse } from "@/lib/rate-limit"; export const dynamic = "force-dynamic"; /** Global controls for THE shared match: speed, jump-to-minute, restart. */ export async function POST(req: NextRequest) { + const limited = rateLimitResponse(req, "sim", { limit: 30, windowMs: 60_000 }); + if (limited) return limited; + let body: SimControl; try { body = (await req.json()) as SimControl; diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index aff867c..f1ee0ea 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { addTask, listTasks } from "@/lib/store"; +import { rateLimitResponse } from "@/lib/rate-limit"; import { clampPriority, STAFF_ROLES, type StaffRole, type TaskOrigin } from "@/shared/models"; export const dynamic = "force-dynamic"; @@ -33,6 +34,9 @@ interface TaskInput { } export async function POST(req: NextRequest) { + const limited = rateLimitResponse(req, "tasks", { limit: 60, windowMs: 60_000 }); + if (limited) return limited; + let body: TaskInput; try { body = (await req.json()) as TaskInput; diff --git a/src/app/api/triage/route.ts b/src/app/api/triage/route.ts index 19fc09e..c39e34d 100644 --- a/src/app/api/triage/route.ts +++ b/src/app/api/triage/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { Type } from "@google/genai"; import { geminiErrorMessage, generateJson } from "@/lib/gemini"; +import { rateLimitResponse } from "@/lib/rate-limit"; import { DEMO_VENUE } from "@/shared/constants"; import type { StadiumEvent, TriageResult } from "@/shared/models"; @@ -46,6 +47,9 @@ const SYSTEM = `You are the duty manager's copilot in the operations control roo /** POST an incident + its source events; returns a Gemini TriageResult. */ export async function POST(req: NextRequest) { + const limited = rateLimitResponse(req, "triage", { limit: 30, windowMs: 60_000 }); + if (limited) return limited; + let body: TriageRequest; try { body = (await req.json()) as TriageRequest; diff --git a/src/components/dashboard-parts.tsx b/src/components/dashboard-parts.tsx new file mode 100644 index 0000000..ca84758 --- /dev/null +++ b/src/components/dashboard-parts.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { DEMO_VENUE } from "@/shared/constants"; +import type { + HandoverReport, + Incident, + MatchPhase, + OpsBriefing, + StadiumEvent, +} from "@/shared/models"; +import { ACCENT, CRITICAL_TEXT, PHASE_LABEL, SEVERITY_META, STATUS } from "@/lib/theme"; +import { Tag } from "@/components/primitives"; +import { Modal } from "@/components/modal"; + +/** One line of the live telemetry feed, rendered by signal type. */ +export function EventLine({ event }: { event: StadiumEvent }) { + const zone = (id: string) => DEMO_VENUE.zones.find((z) => z.id === id)?.name ?? id; + const gate = (id: string) => DEMO_VENUE.gates.find((g) => g.id === id)?.name ?? id; + const phaseLabel = (p: MatchPhase) => PHASE_LABEL[p]; + switch (event.type) { + case "match": + return ( + + Match {phaseLabel(event.phase)} + {event.phase === "goal" && " — GOAL"} + {event.note ? · {event.note} : null} + + ); + case "radio-log": + return ( + + {event.channel} {event.from}:{" "} + {event.message} + + ); + case "gate-flow": + return ( + + Gate {gate(event.gateId)} · {event.entriesPerMinute}/min · queue{" "} + {event.queueLength} + + ); + case "crowd-density": + return ( + + Density {zone(event.zoneId)} at {event.densityPct}% + + ); + case "medical": + return ( + + Medical {event.description} · {zone(event.zoneId)} + {event.severityHint ? ( + · {event.severityHint} + ) : null} + + ); + case "weather": + return ( + + Weather {event.condition}, {event.tempC}°C + {event.note ? · {event.note} : null} + + ); + } +} + +/** One incident in the ops queue: severity, AI triage, and status controls. */ +export function IncidentCard({ + incident, + busy, + onTriage, + onStatus, +}: { + incident: Incident; + busy: boolean; + onTriage: () => void; + onStatus: (status: Incident["status"]) => void; +}) { + const sev = SEVERITY_META[incident.severity]; + const resolved = incident.status === "resolved"; + return ( +
  • +
    +
    +

    + + ● {sev.label} + {" "} + + · {incident.category} · {incident.createdAtMinute}′ · {incident.status} + +

    +

    {incident.title}

    +
    +
    + + {incident.triage ? ( +
    +

    + AI triage +

    +

    {incident.triage.summary}

    +

    {incident.triage.rationale}

    +
      + {incident.triage.recommendedActions.map((a, i) => ( +
    • + + {a.priority} + + + {a.action} {a.assignTo} + +
    • + ))} +
    + {incident.triage.escalate && ( +

    + ▲ Escalate to venue director +

    + )} +
    + ) : null} + + {!resolved && ( +
    + {!incident.triage && ( + + )} + {incident.status === "open" && ( + + )} + +
    + )} +
  • + ); +} + +/** Modal for a generated ops briefing / handover, or a loading/error state. */ +export function DocOverlay({ + doc, + onClose, +}: { + doc: + | { kind: "briefing"; data: OpsBriefing } + | { kind: "handover"; data: HandoverReport } + | { kind: "loading"; label: string } + | { kind: "error"; message: string }; + onClose: () => void; +}) { + return ( + + {doc.kind === "loading" && ( +

    {doc.label}

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

    + ● Something went wrong +

    +

    {doc.message}

    + + )} + {doc.kind === "briefing" && ( + <> +

    + Ops briefing · minute {doc.data.generatedAtMinute} +

    +

    {doc.data.headline}

    +

    {doc.data.situation}

    +

    + Watch items +

    +
      + {doc.data.watchItems.map((item, i) => ( +
    • {item}
    • + ))} +
    +

    + Crowd outlook +

    +

    {doc.data.crowdOutlook}

    + + )} + {doc.kind === "handover" && ( + <> +

    + Shift handover · {doc.data.shift} +

    +

    {doc.data.narrative}

    +

    + For the next shift +

    +
      + {doc.data.actionsForNextShift.map((item, i) => ( +
    • {item}
    • + ))} +
    + + )} + +
    + ); +} diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index 8da4caf..aaf06c8 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -7,19 +7,26 @@ import type { EvacuationPlan, HandoverReport, Incident, - MatchPhase, OpsBriefing, StadiumEvent, TriageResult, WeatherForecast, } from "@/shared/models"; -import { ACCENT, PHASE_LABEL, SEVERITY_META, STATUS, densityState } from "@/lib/theme"; +import { + ACCENT, + CRITICAL_TEXT, + PHASE_LABEL, + SEVERITY_META, + STATUS, + densityState, +} from "@/lib/theme"; import { trafficAt } from "@/lib/traffic/model"; import { insideEstimate, useMatchStream, worstDensityPct } from "@/lib/useMatchStream"; import { SCENARIO } from "@/lib/simulator/scenario"; import { computeWarnings, type EarlyWarning } from "@/lib/predict"; -import { Panel, StatRow, Tag } from "@/components/primitives"; +import { Panel, StatRow } from "@/components/primitives"; import { Modal } from "@/components/modal"; +import { DocOverlay, EventLine, IncidentCard } from "@/components/dashboard-parts"; import { VenueMap } from "@/components/venue-map"; import { Copilot, type ToolCall } from "@/components/copilot"; import { VoiceRadio } from "@/components/voice-radio"; @@ -35,7 +42,8 @@ function triageContext(incident: Incident, events: StadiumEvent[]): StadiumEvent const gate = DEMO_VENUE.gates.find((g) => g.id === e.gateId); if (gate?.zoneId === incident.zoneId) return true; } - if (e.type === "radio-log" && Math.abs(e.atMinute - incident.createdAtMinute) <= 10) return true; + if (e.type === "radio-log" && Math.abs(e.atMinute - incident.createdAtMinute) <= 10) + return true; return incident.sourceEventIds.includes(e.id); }); const picked = (related.length > 0 ? related : events).slice(0, 15); @@ -67,7 +75,10 @@ export default function Dashboard() { const [speed, setSpeed] = useState(SIM_TICK_MS); /** Sim controls are GLOBAL — every connected view follows the shared match. */ - async function simControl(body: { type: "speed"; tickMs: number } | { type: "jump"; toMinute: number } | { type: "restart" }) { + async function simControl( + body: + { type: "speed"; tickMs: number } | { type: "jump"; toMinute: number } | { type: "restart" }, + ) { try { await fetch("/api/sim", { method: "POST", @@ -147,7 +158,10 @@ export default function Dashboard() { } async function generateDoc(type: "briefing" | "handover") { - setDoc({ kind: "loading", label: type === "briefing" ? "Writing ops briefing…" : "Writing handover…" }); + setDoc({ + kind: "loading", + label: type === "briefing" ? "Writing ops briefing…" : "Writing handover…", + }); try { const res = await fetch(`/api/briefing${type === "handover" ? "?type=handover" : ""}`, { method: "POST", @@ -416,7 +430,10 @@ export default function Dashboard() {
    -

    +

    MatchDay Command

    @@ -454,7 +471,9 @@ export default function Dashboard() {

    -
    +

    {gate.name}

    {emergencyActive ? ( -

    +

    EGRESS

    ) : ( @@ -611,7 +635,9 @@ export default function Dashboard() { {forecast ? (
    - {Math.round(forecast.tempC)}°C + + {Math.round(forecast.tempC)}°C + {forecast.condition}

    {forecast.summary}

    @@ -639,11 +665,16 @@ export default function Dashboard() {
      {traffic.advisories.map((a) => (
    • -
    • @@ -656,6 +687,11 @@ export default function Dashboard() { title="Live feed" action={} bodyClassName="max-h-[46vh] overflow-y-auto" + bodyProps={{ + tabIndex: 0, + role: "region", + "aria-label": "Live event feed (scrollable)", + }} > {state.events.length === 0 ? (

      Waiting for gate telemetry…

      @@ -685,14 +721,22 @@ export default function Dashboard() { No incidents — feed nominal. Rules open incidents automatically.

      ) : ( -
        +
          {state.incidents.map((incident) => ( runTriage(incident)} - onStatus={(status) => dispatch({ type: "status", incidentId: incident.id, status })} + onStatus={(status) => + dispatch({ type: "status", incidentId: incident.id, status }) + } /> ))}
        @@ -708,7 +752,7 @@ export default function Dashboard() { 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?

        @@ -740,264 +784,63 @@ export default function Dashboard() { className="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-[#1a1a19] p-5" style={{ borderColor: `${STATUS.critical}66` }} > -

        - Evacuation plan · minute {emergency.plan.generatedAtMinute} +

        + Evacuation plan · minute {emergency.plan.generatedAtMinute} +

        +
        +

        + PA announcement — read verbatim

        -
        -

        - PA announcement — read verbatim -

        -

        - “{emergency.plan.paAnnouncement}” -

        -
        -

        - Command summary -

        -

        - {emergency.plan.commandSummary} +

        + “{emergency.plan.paAnnouncement}”

        -

        - Zone orders (in sequence) -

        -
          - {[...emergency.plan.zoneOrders] - .sort((a, b) => a.priority - b.priority) - .map((o) => ( -
        • - - {o.priority} - - - {o.zoneName}:{" "} - {o.instruction}{" "} - → {o.exitVia} - -
        • - ))} -
        -

        - Staff work orders were dispatched to the staff console automatically. -

        - +
        +

        + Command summary +

        +

        + {emergency.plan.commandSummary} +

        +

        + Zone orders (in sequence) +

        +
          + {[...emergency.plan.zoneOrders] + .sort((a, b) => a.priority - b.priority) + .map((o) => ( +
        • + + {o.priority} + + + {o.zoneName}:{" "} + {o.instruction}{" "} + → {o.exitVia} + +
        • + ))} +
        +

        + Staff work orders were dispatched to the staff console automatically. +

        + )}
        - Simulated telemetry · AI output is generated from on-screen events only · Google PromptWars 2026 + Simulated telemetry · AI output is generated from on-screen events only · Google PromptWars + 2026
    ); } - -function EventLine({ event }: { event: StadiumEvent }) { - const zone = (id: string) => DEMO_VENUE.zones.find((z) => z.id === id)?.name ?? id; - const gate = (id: string) => DEMO_VENUE.gates.find((g) => g.id === id)?.name ?? id; - const phaseLabel = (p: MatchPhase) => PHASE_LABEL[p]; - switch (event.type) { - case "match": - return ( - - Match {phaseLabel(event.phase)} - {event.phase === "goal" && " — GOAL"} - {event.note ? · {event.note} : null} - - ); - case "radio-log": - return ( - - {event.channel} {event.from}: {event.message} - - ); - case "gate-flow": - return ( - - Gate {gate(event.gateId)} · {event.entriesPerMinute}/min · queue {event.queueLength} - - ); - case "crowd-density": - return ( - - Density {zone(event.zoneId)} at {event.densityPct}% - - ); - case "medical": - return ( - - Medical {event.description} · {zone(event.zoneId)} - {event.severityHint ? · {event.severityHint} : null} - - ); - case "weather": - return ( - - Weather {event.condition}, {event.tempC}°C - {event.note ? · {event.note} : null} - - ); - } -} - -function IncidentCard({ - incident, - busy, - onTriage, - onStatus, -}: { - incident: Incident; - busy: boolean; - onTriage: () => void; - onStatus: (status: Incident["status"]) => void; -}) { - const sev = SEVERITY_META[incident.severity]; - const resolved = incident.status === "resolved"; - return ( -
  • -
    -
    -

    - - ● {sev.label} - {" "} - - · {incident.category} · {incident.createdAtMinute}′ · {incident.status} - -

    -

    {incident.title}

    -
    -
    - - {incident.triage ? ( -
    -

    - AI triage -

    -

    {incident.triage.summary}

    -

    {incident.triage.rationale}

    -
      - {incident.triage.recommendedActions.map((a, i) => ( -
    • - - {a.priority} - - - {a.action} {a.assignTo} - -
    • - ))} -
    - {incident.triage.escalate && ( -

    - ▲ Escalate to venue director -

    - )} -
    - ) : null} - - {!resolved && ( -
    - {!incident.triage && ( - - )} - {incident.status === "open" && ( - - )} - -
    - )} -
  • - ); -} - -function DocOverlay({ - doc, - onClose, -}: { - doc: - | { kind: "briefing"; data: OpsBriefing } - | { kind: "handover"; data: HandoverReport } - | { kind: "loading"; label: string } - | { kind: "error"; message: string }; - onClose: () => void; -}) { - return ( - - {doc.kind === "loading" &&

    {doc.label}

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

    - ● Something went wrong -

    -

    {doc.message}

    - - )} - {doc.kind === "briefing" && ( - <> -

    - Ops briefing · minute {doc.data.generatedAtMinute} -

    -

    {doc.data.headline}

    -

    {doc.data.situation}

    -

    Watch items

    -
      - {doc.data.watchItems.map((item, i) => ( -
    • {item}
    • - ))} -
    -

    Crowd outlook

    -

    {doc.data.crowdOutlook}

    - - )} - {doc.kind === "handover" && ( - <> -

    - Shift handover · {doc.data.shift} -

    -

    {doc.data.narrative}

    -

    For the next shift

    -
      - {doc.data.actionsForNextShift.map((item, i) => ( -
    • {item}
    • - ))} -
    - - )} - -
    - ); -} diff --git a/src/components/modal.tsx b/src/components/modal.tsx index 45e7a2b..e5d4d43 100644 --- a/src/components/modal.tsx +++ b/src/components/modal.tsx @@ -2,8 +2,7 @@ import { useEffect, useRef, type CSSProperties, type ReactNode } from "react"; -const FOCUSABLE = - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; +const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; /** * Accessible modal dialog: it is labelled (`aria-label`), focus-trapped, closes diff --git a/src/components/primitives.tsx b/src/components/primitives.tsx index 077d768..c2cf68d 100644 --- a/src/components/primitives.tsx +++ b/src/components/primitives.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { HTMLAttributes, ReactNode } from "react"; /** Bordered panel with an uppercase header — the base unit of both dashboards. */ export function Panel({ @@ -7,27 +7,35 @@ export function Panel({ className = "", bodyClassName = "", action, + bodyProps, }: { title: string; children: ReactNode; className?: string; bodyClassName?: string; action?: ReactNode; + /** Extra attributes for the body div — e.g. tabIndex/role to make a + * scrollable body keyboard-focusable. */ + bodyProps?: HTMLAttributes; }) { return (
    -

    {title}

    +

    + {title} +

    {action}
    -
    {children}
    +
    + {children} +
    ); } export function Tag({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/components/staff-dashboard.tsx b/src/components/staff-dashboard.tsx index bf67037..1be5111 100644 --- a/src/components/staff-dashboard.tsx +++ b/src/components/staff-dashboard.tsx @@ -147,7 +147,8 @@ export default function StaffDashboard() { createdAtMinute: inc.createdAtMinute, role: roleForCategory(inc.category), title: inc.title, - detail: inc.triage?.summary ?? `Respond to ${inc.category} incident. Triage pending in ops.`, + detail: + inc.triage?.summary ?? `Respond to ${inc.category} incident. Triage pending in ops.`, priority: priorityFromSeverity(inc.severity), origin: "incident", status: "pending", @@ -168,7 +169,10 @@ export default function StaffDashboard() { } if (forecast) { - const wet = forecast.precipProbPct >= 60 || forecast.condition === "rain" || forecast.condition === "storm"; + const wet = + forecast.precipProbPct >= 60 || + forecast.condition === "rain" || + forecast.condition === "storm"; const hot = forecast.condition === "heat" || forecast.tempC >= 34; const cold = forecast.condition === "cold" || forecast.tempC <= 12; if (wet) @@ -177,7 +181,8 @@ export default function StaffDashboard() { createdAtMinute: state.minute, role: "facilities", title: "Wet-weather readiness", - detail: "Open poncho points at both concourses, cover walkways, check drainage and roof runoff.", + detail: + "Open poncho points at both concourses, cover walkways, check drainage and roof runoff.", priority: 1, origin: "weather", status: "pending", @@ -189,7 +194,8 @@ export default function StaffDashboard() { createdAtMinute: state.minute, role: "concessions", title: "Heat readiness", - detail: "Push water and cold drinks to forward stands, run misting fans, brief medical on heat cases.", + detail: + "Push water and cold drinks to forward stands, run misting fans, brief medical on heat cases.", priority: 1, origin: "weather", status: "pending", @@ -238,12 +244,13 @@ export default function StaffDashboard() {
    -

    +

    MatchDay Command · Staff console

    -

    - {DEMO_VENUE.name} · your tasks for this shift -

    +

    {DEMO_VENUE.name} · your tasks for this shift

    {state.minute}′ @@ -283,7 +290,9 @@ export default function StaffDashboard() { {forecast ? (
    - {Math.round(forecast.tempC)}°C + + {Math.round(forecast.tempC)}°C + {forecast.condition}

    {forecast.summary}

    @@ -292,11 +301,17 @@ export default function StaffDashboard() {

    {forecast.horizon.map((h) => ( -
    +

    {h.label}

    {Math.round(h.tempC)}°

    {h.condition}

    -

    = 50 ? STATUS.warning : INK.muted }}> +

    = 50 ? STATUS.warning : INK.muted }} + > {h.precipProbPct}%

    @@ -310,7 +325,13 @@ export default function StaffDashboard() { min {demand.plan.generatedAtMinute} : undefined} + action={ + demand ? ( + + min {demand.plan.generatedAtMinute} + + ) : undefined + } > {demand ? (
    @@ -331,8 +352,12 @@ export default function StaffDashboard() { .map((l) => ( {itemLabel(l.item)} - {l.predictedUnits.toLocaleString()} - {l.currentStock.toLocaleString()} + + {l.predictedUnits.toLocaleString()} + + + {l.currentStock.toLocaleString()} + 0 ? STATUS.serious : STATUS.good }} @@ -370,7 +395,8 @@ export default function StaffDashboard() { {byRole.length === 0 ? (

    - No open tasks — the system dispatches prep, stocking, traffic, and incident tasks here as they arise. + No open tasks — the system dispatches prep, stocking, traffic, and incident tasks + here as they arise.

    ) : (
    @@ -384,7 +410,12 @@ export default function StaffDashboard() {
      {roleTasks.map((t) => ( - advance(t)} /> + advance(t)} + /> ))}
    @@ -396,7 +427,8 @@ export default function StaffDashboard() {
    - Tasks dispatched from live incidents, weather, traffic, and the AI demand plan · Google PromptWars 2026 + Tasks dispatched from live incidents, weather, traffic, and the AI demand plan · Google + PromptWars 2026
    ); diff --git a/src/components/tournament-dashboard.tsx b/src/components/tournament-dashboard.tsx index 005ffda..2cd2907 100644 --- a/src/components/tournament-dashboard.tsx +++ b/src/components/tournament-dashboard.tsx @@ -54,10 +54,15 @@ export default function TournamentDashboard() {
    -

    +

    MatchDay Command · Tournament

    -

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

    +

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

    - + ← Ops room
    @@ -99,7 +107,9 @@ export default function TournamentDashboard() {

    {venue.name}

    -

    {venue.city} · {venue.capacity.toLocaleString()} cap

    +

    + {venue.city} · {venue.capacity.toLocaleString()} cap +

    {d.label}
    -
    +
    @@ -147,8 +163,14 @@ export default function TournamentDashboard() {
    -

    {s.statusLine}

    - {venue.live &&

    Open ops room →

    } +

    + {s.statusLine} +

    + {venue.live && ( +

    + Open ops room → +

    + )}
    ); return venue.live ? ( @@ -163,7 +185,8 @@ export default function TournamentDashboard() {
    - Meridian Arena is the live shared simulation; sister venues are derived summaries · Google PromptWars 2026 + Meridian Arena is the live shared simulation; sister venues are derived summaries · Google + PromptWars 2026
    ); diff --git a/src/components/venue-map.tsx b/src/components/venue-map.tsx index d5de870..2088a11 100644 --- a/src/components/venue-map.tsx +++ b/src/components/venue-map.tsx @@ -5,18 +5,17 @@ import { DEMO_VENUE, VENUE_LOCATION } from "@/shared/constants"; import type { LatLng, TrafficState } from "@/shared/models"; import { CONGESTION_COLOR, STATUS } from "@/lib/theme"; -/* eslint-disable @typescript-eslint/no-explicit-any */ -// Google Maps objects are untyped here (no @types/google.maps dep); kept local. +// The Maps JS API is typed via src/types/google-maps.d.ts (a minimal local +// surface, so we avoid the full @types/google.maps dependency). -let mapsPromise: Promise | null = null; +let mapsPromise: Promise | null = null; -function loadGoogleMaps(key: string): Promise { +function loadGoogleMaps(key: string): Promise { if (typeof window === "undefined") return Promise.reject(new Error("no window")); - const w = window as any; - if (w.google?.maps) return Promise.resolve(w.google.maps); + if (window.google?.maps) return Promise.resolve(window.google.maps); if (mapsPromise) return mapsPromise; mapsPromise = new Promise((resolve, reject) => { - w.__mdcMapInit = () => resolve(w.google.maps); + window.__mdcMapInit = () => resolve(window.google!.maps); const s = document.createElement("script"); s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&callback=__mdcMapInit&v=weekly`; s.async = true; @@ -44,9 +43,9 @@ function lotColor(ratio: number): string { export function VenueMap({ traffic }: { traffic: TrafficState }) { const [mode, setMode] = useState<"loading" | "google" | "sim">("loading"); const divRef = useRef(null); - const mapsRef = useRef(null); - const mapRef = useRef(null); - const overlaysRef = useRef([]); + const mapsRef = useRef(null); + const mapRef = useRef(null); + const overlaysRef = useRef([]); const trafficRef = useRef(traffic); trafficRef.current = traffic; @@ -64,19 +63,16 @@ export function VenueMap({ traffic }: { traffic: TrafficState }) { .then((maps) => { if (cancelled || !divRef.current) return; mapsRef.current = maps; - mapRef.current = new maps.Map(divRef.current, { + const map = new maps.Map(divRef.current, { center: VENUE_LOCATION, zoom: 14, disableDefaultUI: true, zoomControl: true, styles: DARK_STYLE, }); - new maps.TrafficLayer().setMap(mapRef.current); - new maps.Marker({ - position: VENUE_LOCATION, - map: mapRef.current, - title: DEMO_VENUE.name, - }); + mapRef.current = map; + new maps.TrafficLayer().setMap(map); + new maps.Marker({ position: VENUE_LOCATION, map, title: DEMO_VENUE.name }); setMode("google"); drawOverlays(); }) @@ -86,7 +82,7 @@ export function VenueMap({ traffic }: { traffic: TrafficState }) { 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.forEach((o) => o.setMap(null)); overlaysRef.current = []; mapRef.current = null; mapsRef.current = null; @@ -179,7 +175,12 @@ function SchematicMap({ traffic }: { traffic: TrafficState }) { const venue = px(VENUE_LOCATION); return ( - + {traffic.corridors.map((c) => { const edge = px(c.path[0]); @@ -206,7 +207,15 @@ function SchematicMap({ traffic }: { traffic: TrafficState }) { const ratio = l.occupancy / l.capacity; return ( - + {Math.round(ratio * 100)}% @@ -214,7 +223,14 @@ function SchematicMap({ traffic }: { traffic: TrafficState }) { ); })} - + {DEMO_VENUE.name} diff --git a/src/components/voice-radio.tsx b/src/components/voice-radio.tsx index aa0180d..a807446 100644 --- a/src/components/voice-radio.tsx +++ b/src/components/voice-radio.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { STATUS } from "@/lib/theme"; +import { CRITICAL_TEXT, STATUS } from "@/lib/theme"; /** * Push-to-talk radio reports via the browser's built-in Web Speech API @@ -56,7 +56,10 @@ export function VoiceRadio({ onTranscript }: { onTranscript: (text: string) => v if (status === "unsupported") { return ( - + mic n/a ); @@ -67,7 +70,9 @@ export function VoiceRadio({ onTranscript }: { onTranscript: (text: string) => v onClick={start} title="Push to talk — speak a radio report into the feed" className="rounded border border-white/10 px-2 py-0.5 text-[11px] text-white hover:bg-white/5" - style={status === "listening" ? { borderColor: STATUS.critical, color: STATUS.critical } : undefined} + style={ + status === "listening" ? { borderColor: STATUS.critical, color: CRITICAL_TEXT } : undefined + } > {status === "listening" ? "● Listening…" : status === "error" ? "🎙 Retry" : "🎙 Radio report"} diff --git a/src/lib/gemini.test.ts b/src/lib/gemini.test.ts index 5eed292..2286686 100644 --- a/src/lib/gemini.test.ts +++ b/src/lib/gemini.test.ts @@ -5,7 +5,8 @@ import { generateJson, isRetryable, modelChain } from "./gemini"; afterEach(() => vi.unstubAllEnvs()); /** An SDK-style error whose message is the JSON blob Gemini returns. */ -const gErr = (code: number) => new Error(JSON.stringify({ error: { code, message: `err ${code}` } })); +const gErr = (code: number) => + new Error(JSON.stringify({ error: { code, message: `err ${code}` } })); /** Fake GoogleGenAI whose per-model outcome is scripted; records call order. */ function fakeClient(script: Record) { diff --git a/src/lib/predict.test.ts b/src/lib/predict.test.ts index 1d660ba..f8a7846 100644 --- a/src/lib/predict.test.ts +++ b/src/lib/predict.test.ts @@ -36,7 +36,10 @@ function gateQueueSeries(gateId: string, points: Array<[number, number]>): GateF describe("computeWarnings — zone density projection", () => { it("warns when a zone is trending toward the alert threshold", () => { - const events = zoneSeries("concourse-north", [[40, 70], [50, 80]]); + const events = zoneSeries("concourse-north", [ + [40, 70], + [50, 80], + ]); const [w] = computeWarnings(events, 50); expect(w.kind).toBe("zone"); expect(w.targetId).toBe("concourse-north"); @@ -46,18 +49,45 @@ describe("computeWarnings — zone density projection", () => { }); it("does not warn about a zone that has already breached — that's an incident", () => { - const events = zoneSeries("concourse-north", [[40, 80], [50, 90]]); + const events = zoneSeries("concourse-north", [ + [40, 80], + [50, 90], + ]); expect(computeWarnings(events, 50)).toHaveLength(0); }); it("does not warn on a flat or falling trend", () => { - expect(computeWarnings(zoneSeries("concourse-north", [[40, 70], [50, 70]]), 50)).toHaveLength(0); - expect(computeWarnings(zoneSeries("concourse-north", [[40, 80], [50, 70]]), 50)).toHaveLength(0); + expect( + computeWarnings( + zoneSeries("concourse-north", [ + [40, 70], + [50, 70], + ]), + 50, + ), + ).toHaveLength(0); + expect( + computeWarnings( + zoneSeries("concourse-north", [ + [40, 80], + [50, 70], + ]), + 50, + ), + ).toHaveLength(0); }); it("suppresses breaches projected beyond the ETA horizon", () => { // +0.33%/min from 71 → ~42 min to hit 85, well past the 20-min horizon. - expect(computeWarnings(zoneSeries("concourse-north", [[40, 70], [43, 71]]), 43)).toHaveLength(0); + expect( + computeWarnings( + zoneSeries("concourse-north", [ + [40, 70], + [43, 71], + ]), + 43, + ), + ).toHaveLength(0); }); it("needs at least two samples in the window to project", () => { @@ -65,14 +95,20 @@ describe("computeWarnings — zone density projection", () => { }); it("skips gate-plaza zones — those are tracked via queue length", () => { - const events = zoneSeries("gate-plaza-north", [[40, 70], [50, 80]]); + const events = zoneSeries("gate-plaza-north", [ + [40, 70], + [50, 80], + ]); expect(computeWarnings(events, 50)).toHaveLength(0); }); }); describe("computeWarnings — gate queue projection", () => { it("warns when a queue is climbing toward the alert length", () => { - const events = gateQueueSeries("gate-a", [[40, 250], [44, 290]]); + const events = gateQueueSeries("gate-a", [ + [40, 250], + [44, 290], + ]); const [w] = computeWarnings(events, 44); expect(w.kind).toBe("gate"); expect(w.targetId).toBe("gate-a"); @@ -82,18 +118,34 @@ describe("computeWarnings — gate queue projection", () => { it("ignores a slowly growing queue below the rate floor", () => { // +2/min is under the 8/min minimum rate for a gate warning. - expect(computeWarnings(gateQueueSeries("gate-a", [[40, 250], [50, 270]]), 50)).toHaveLength(0); + expect( + computeWarnings( + gateQueueSeries("gate-a", [ + [40, 250], + [50, 270], + ]), + 50, + ), + ).toHaveLength(0); }); }); describe("computeWarnings — ordering", () => { it("returns the most imminent warning first", () => { const events: StadiumEvent[] = [ - ...zoneSeries("concourse-north", [[40, 70], [50, 80]]), // eta ~5 - ...gateQueueSeries("gate-a", [[40, 250], [44, 290]]), // eta ~1 + ...zoneSeries("concourse-north", [ + [40, 70], + [50, 80], + ]), // eta ~5 + ...gateQueueSeries("gate-a", [ + [40, 250], + [44, 290], + ]), // eta ~1 ]; const warnings = computeWarnings(events, 50); - expect(warnings.map((w) => w.etaMin)).toEqual([...warnings.map((w) => w.etaMin)].sort((a, b) => a - b)); + expect(warnings.map((w) => w.etaMin)).toEqual( + [...warnings.map((w) => w.etaMin)].sort((a, b) => a - b), + ); expect(warnings[0].kind).toBe("gate"); }); }); diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..e01d8f8 --- /dev/null +++ b/src/lib/rate-limit.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { __resetRateLimits, clientKey, rateLimit } from "./rate-limit"; + +beforeEach(() => __resetRateLimits()); + +const opts = { limit: 3, windowMs: 1000 }; + +describe("rateLimit", () => { + it("allows requests up to the limit within a window", () => { + for (let i = 0; i < 3; i++) expect(rateLimit("k", opts, 1000).ok).toBe(true); + }); + + it("blocks once the limit is exceeded and reports a retry delay", () => { + for (let i = 0; i < 3; i++) rateLimit("k", opts, 1000); + const blocked = rateLimit("k", opts, 1000); + expect(blocked.ok).toBe(false); + expect(blocked.retryAfterSec).toBeGreaterThan(0); + }); + + it("resets after the window elapses", () => { + for (let i = 0; i < 3; i++) rateLimit("k", opts, 1000); + expect(rateLimit("k", opts, 1500).ok).toBe(false); // still inside window + expect(rateLimit("k", opts, 2000).ok).toBe(true); // window reset at 2000 + }); + + it("tracks distinct keys independently", () => { + rateLimit("a", { limit: 1, windowMs: 1000 }, 1000); + expect(rateLimit("a", { limit: 1, windowMs: 1000 }, 1000).ok).toBe(false); + expect(rateLimit("b", { limit: 1, windowMs: 1000 }, 1000).ok).toBe(true); + }); +}); + +describe("clientKey", () => { + it("uses the first x-forwarded-for hop", () => { + const req = new Request("http://x", { headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" } }); + expect(clientKey(req)).toBe("1.2.3.4"); + }); + + it("falls back to 'unknown' when the header is absent", () => { + expect(clientKey(new Request("http://x"))).toBe("unknown"); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..9a73955 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,80 @@ +/** + * Fixed-window in-memory rate limiter. The app runs as a single Cloud Run + * instance (--max-instances 1) by design, so a module-level Map is sufficient; + * a restart simply resets every window. Guards the AI/mutating routes against + * a single client hammering them (cost + shared-demo disruption). + */ + +interface Window { + count: number; + resetAt: number; +} + +/** Keep the map from growing unbounded under many distinct clients. */ +const MAX_KEYS = 10_000; +const windows = new Map(); + +export interface RateLimitOptions { + /** Max requests allowed per window. */ + limit: number; + /** Window length in milliseconds. */ + windowMs: number; +} + +export interface RateLimitResult { + ok: boolean; + /** Seconds until the window resets (for a Retry-After header) when blocked. */ + retryAfterSec: number; +} + +export function rateLimit( + key: string, + opts: RateLimitOptions, + now: number = Date.now(), +): RateLimitResult { + const existing = windows.get(key); + + if (!existing || now >= existing.resetAt) { + if (windows.size >= MAX_KEYS) { + for (const [k, w] of windows) if (now >= w.resetAt) windows.delete(k); + } + windows.set(key, { count: 1, resetAt: now + opts.windowMs }); + return { ok: true, retryAfterSec: 0 }; + } + + if (existing.count >= opts.limit) { + return { ok: false, retryAfterSec: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) }; + } + + existing.count += 1; + return { ok: true, retryAfterSec: 0 }; +} + +/** Best-effort client identity from the proxy chain (Cloud Run sets XFF). */ +export function clientKey(req: Request): string { + const fwd = req.headers.get("x-forwarded-for"); + return fwd?.split(",")[0]?.trim() || "unknown"; +} + +/** + * Route guard: returns a 429 Response if the client is over the limit for + * `name`, or null to proceed. Usage: `const r = rateLimitResponse(req, "triage", + * {limit: 30, windowMs: 60_000}); if (r) return r;` + */ +export function rateLimitResponse( + req: Request, + name: string, + opts: RateLimitOptions, +): Response | null { + const rl = rateLimit(`${name}:${clientKey(req)}`, opts); + if (rl.ok) return null; + return Response.json( + { error: "Too many requests — slow down and try again shortly." }, + { status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } }, + ); +} + +/** Reset all windows — test hook only. */ +export function __resetRateLimits(): void { + windows.clear(); +} diff --git a/src/lib/simulator/detector.test.ts b/src/lib/simulator/detector.test.ts index aeca6dc..6839f4b 100644 --- a/src/lib/simulator/detector.test.ts +++ b/src/lib/simulator/detector.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vitest"; import { IncidentDetector } from "./detector"; -import { - DENSITY_ALERT_PCT, - DENSITY_CRITICAL_PCT, - QUEUE_ALERT_LENGTH, -} from "@/shared/constants"; +import { DENSITY_ALERT_PCT, DENSITY_CRITICAL_PCT, QUEUE_ALERT_LENGTH } from "@/shared/constants"; import type { CrowdDensityEvent, GateFlowEvent, @@ -24,7 +20,13 @@ function gate(gateId: string, queueLength: number): GateFlowEvent { return { ...base(), type: "gate-flow", gateId, entriesPerMinute: 100, queueLength }; } function medical(hint?: MedicalEvent["severityHint"]): MedicalEvent { - return { ...base(), type: "medical", zoneId: "seating-bowl", description: "fainting", severityHint: hint }; + return { + ...base(), + type: "medical", + zoneId: "seating-bowl", + description: "fainting", + severityHint: hint, + }; } function weather(condition: WeatherEvent["condition"]): WeatherEvent { return { ...base(), type: "weather", condition, tempC: 30 }; @@ -85,7 +87,9 @@ describe("IncidentDetector — gate queue rules", () => { describe("IncidentDetector — medical rules", () => { it("maps severity hints onto incident severity", () => { - expect(new IncidentDetector().process([medical("life-threatening")], 30)[0].severity).toBe("critical"); + expect(new IncidentDetector().process([medical("life-threatening")], 30)[0].severity).toBe( + "critical", + ); expect(new IncidentDetector().process([medical("serious")], 30)[0].severity).toBe("high"); expect(new IncidentDetector().process([medical("minor")], 30)[0].severity).toBe("medium"); expect(new IncidentDetector().process([medical(undefined)], 30)[0].severity).toBe("medium"); diff --git a/src/lib/simulator/engine.test.ts b/src/lib/simulator/engine.test.ts new file mode 100644 index 0000000..8b094d5 --- /dev/null +++ b/src/lib/simulator/engine.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { SimulationEngine } from "./engine"; + +/** Run the sim for `minutes` ticks and capture the density signal (rng-driven). */ +function densityTrace(seed: number, minutes = 90): number[] { + const engine = new SimulationEngine(seed); + const out: number[] = []; + for (let i = 0; i < minutes; i++) { + for (const ev of engine.tick()) { + if (ev.type === "crowd-density") out.push(ev.densityPct); + } + } + return out; +} + +describe("SimulationEngine", () => { + it("is deterministic for a fixed seed (same story every run)", () => { + expect(densityTrace(42)).toEqual(densityTrace(42)); + }); + + it("produces a different trace for a different seed", () => { + expect(densityTrace(42)).not.toEqual(densityTrace(7)); + }); + + it("advances one minute per tick and fills the venue over time", () => { + const engine = new SimulationEngine(); + expect(engine.clockMinute).toBe(0); + for (let i = 0; i < 90; i++) engine.tick(); + expect(engine.clockMinute).toBe(90); + expect(engine.insideCount).toBeGreaterThan(0); + }); + + it("keeps every emitted density within 0..100", () => { + for (const pct of densityTrace(42, 120)) { + expect(pct).toBeGreaterThanOrEqual(0); + expect(pct).toBeLessThanOrEqual(100); + } + }); +}); diff --git a/src/lib/simulator/engine.ts b/src/lib/simulator/engine.ts index c8242d8..4905a19 100644 --- a/src/lib/simulator/engine.ts +++ b/src/lib/simulator/engine.ts @@ -164,10 +164,14 @@ 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)?.queueLength ?? 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; + const concourseShare = + this.phase === "halftime" ? 0.42 : this.phase === "gates-open" ? 0.3 : 0.1; const jitter = 0.92 + this.rng() * 0.16; if (zone.kind === "concourse") { return Math.round(((this.inside * concourseShare) / 2) * jitter); diff --git a/src/lib/simulator/live.ts b/src/lib/simulator/live.ts index 36df6bc..db518e3 100644 --- a/src/lib/simulator/live.ts +++ b/src/lib/simulator/live.ts @@ -17,9 +17,7 @@ import { SimulationEngine } from "./engine"; type Send = (msg: StreamMessage) => void; export type SimControl = - | { type: "speed"; tickMs: number } - | { type: "jump"; toMinute: number } - | { type: "restart" }; + { type: "speed"; tickMs: number } | { type: "jump"; toMinute: number } | { type: "restart" }; const MAX_MINUTE = 200; const EVENT_LOG_CAP = 500; diff --git a/src/lib/simulator/scenario.ts b/src/lib/simulator/scenario.ts index 09f1b6a..de1b278 100644 --- a/src/lib/simulator/scenario.ts +++ b/src/lib/simulator/scenario.ts @@ -37,7 +37,15 @@ export const SCENARIO: ScenarioBeat[] = [ }, { atMinute: 41, - events: [{ type: "crowd-density", atMinute: 41, zoneId: "concourse-north", occupancy: 8000, densityPct: 89 }], + events: [ + { + type: "crowd-density", + atMinute: 41, + zoneId: "concourse-north", + occupancy: 8000, + densityPct: 89, + }, + ], }, { atMinute: 60, @@ -58,7 +66,13 @@ export const SCENARIO: ScenarioBeat[] = [ { atMinute: 75, events: [ - { type: "weather", atMinute: 75, condition: "storm", tempC: 31, note: "Storm cell 20km west, moving in" }, + { + type: "weather", + atMinute: 75, + condition: "storm", + tempC: 31, + note: "Storm cell 20km west, moving in", + }, { type: "radio-log", atMinute: 75, @@ -92,7 +106,12 @@ export const SCENARIO: ScenarioBeat[] = [ { atMinute: 165, events: [ - { type: "match", atMinute: 165, phase: "fulltime", note: "Egress begins, all gates to exit mode" }, + { + type: "match", + atMinute: 165, + phase: "fulltime", + note: "Egress begins, all gates to exit mode", + }, { type: "radio-log", atMinute: 165, diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 2770dc9..d9bf816 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -4,19 +4,27 @@ import type { IncidentSeverity, MatchPhase } from "@/shared/models"; /* Dark-surface design tokens — validated for CVD separation + contrast. Status colors never carry meaning alone; always pair with a text label. */ export const INK = { primary: "#ffffff", secondary: "#c3c2b7", muted: "#898781" }; -export const STATUS = { good: "#0ca30c", warning: "#fab219", serious: "#ec835a", critical: "#d03b3b" }; +export const STATUS = { + good: "#0ca30c", + warning: "#fab219", + serious: "#ec835a", + critical: "#d03b3b", +}; export const ACCENT = "#3987e5"; +/** Lighter red for critical *text* on dark surfaces — the #d03b3b background + * tone fails WCAG AA contrast when used as text. Backgrounds keep STATUS.critical. */ +export const CRITICAL_TEXT = "#f2726e"; export const SEVERITY_META: Record = { info: { label: "Info", color: INK.muted }, low: { label: "Low", color: STATUS.good }, medium: { label: "Medium", color: STATUS.warning }, high: { label: "High", color: STATUS.serious }, - critical: { label: "Critical", color: STATUS.critical }, + critical: { label: "Critical", color: CRITICAL_TEXT }, }; export function densityState(pct: number): { label: string; color: string } { - if (pct >= DENSITY_CRITICAL_PCT) return { label: "Critical", color: STATUS.critical }; + if (pct >= DENSITY_CRITICAL_PCT) return { label: "Critical", color: CRITICAL_TEXT }; if (pct >= DENSITY_ALERT_PCT) return { label: "Alert", color: STATUS.serious }; if (pct >= DENSITY_WATCH_PCT) return { label: "Watch", color: STATUS.warning }; return { label: "OK", color: STATUS.good }; diff --git a/src/lib/tournament.ts b/src/lib/tournament.ts index d8af22e..b8e8e09 100644 --- a/src/lib/tournament.ts +++ b/src/lib/tournament.ts @@ -29,9 +29,33 @@ export const TOURNAMENT_VENUES: TournamentVenue[] = [ seed: 1, live: true, }, - { id: "lakeside", name: "Lakeside Stadium", city: "Bhopal", capacity: 38000, clockOffsetMin: 35, seed: 2, live: false }, - { id: "garrison", name: "Garrison Park", city: "Jabalpur", capacity: 30000, clockOffsetMin: -20, seed: 3, live: false }, - { id: "riverbend", name: "River Bend Arena", city: "Ujjain", capacity: 26000, clockOffsetMin: -50, seed: 4, live: false }, + { + id: "lakeside", + name: "Lakeside Stadium", + city: "Bhopal", + capacity: 38000, + clockOffsetMin: 35, + seed: 2, + live: false, + }, + { + id: "garrison", + name: "Garrison Park", + city: "Jabalpur", + capacity: 30000, + clockOffsetMin: -20, + seed: 3, + live: false, + }, + { + id: "riverbend", + name: "River Bend Arena", + city: "Ujjain", + capacity: 26000, + clockOffsetMin: -50, + seed: 4, + live: false, + }, ]; export interface VenueSummary { @@ -75,7 +99,12 @@ export function venueSummary(venue: TournamentVenue, sharedMinute: number): Venu // Incident count drifts up through the day, venue-flavored. const openIncidents = - m <= 10 ? 0 : Math.max(0, Math.floor((m / 55) * (1.4 + noise(venue.seed, 7) * 2)) - (phase === "fulltime" ? 1 : 0)); + m <= 10 + ? 0 + : Math.max( + 0, + Math.floor((m / 55) * (1.4 + noise(venue.seed, 7) * 2)) - (phase === "fulltime" ? 1 : 0), + ); const insideEst = Math.round(venue.capacity * 0.88 * fillFrac); @@ -90,5 +119,14 @@ export function venueSummary(venue: TournamentVenue, sharedMinute: number): Venu ? "Egress underway" : "Nominal"; - return { venueId: venue.id, localMinute: Math.max(0, m), phase, phaseLabel: label, worstDensityPct, openIncidents, insideEst, statusLine }; + return { + venueId: venue.id, + localMinute: Math.max(0, m), + phase, + phaseLabel: label, + worstDensityPct, + openIncidents, + insideEst, + statusLine, + }; } diff --git a/src/lib/traffic/model.test.ts b/src/lib/traffic/model.test.ts index 10d4a60..3e6b6e5 100644 --- a/src/lib/traffic/model.test.ts +++ b/src/lib/traffic/model.test.ts @@ -11,7 +11,9 @@ describe("trafficAt — structure", () => { }); it("is deterministic for the same inputs", () => { - expect(JSON.stringify(trafficAt(45, "gates-open"))).toBe(JSON.stringify(trafficAt(45, "gates-open"))); + expect(JSON.stringify(trafficAt(45, "gates-open"))).toBe( + JSON.stringify(trafficAt(45, "gates-open")), + ); }); }); @@ -30,7 +32,9 @@ describe("trafficAt — load curve", () => { it("surges again at full time and adds an egress advisory", () => { const state = trafficAt(166, "fulltime"); - expect(state.corridors.some((c) => c.congestion === "severe" || c.congestion === "heavy")).toBe(true); + expect(state.corridors.some((c) => c.congestion === "severe" || c.congestion === "heavy")).toBe( + true, + ); const egress = state.advisories.find((a) => a.message.toLowerCase().includes("egress")); expect(egress?.severity).toBe("info"); }); diff --git a/src/lib/traffic/model.ts b/src/lib/traffic/model.ts index d5967a1..d694d0f 100644 --- a/src/lib/traffic/model.ts +++ b/src/lib/traffic/model.ts @@ -22,24 +22,51 @@ const CORRIDORS: Omit[] = [ { id: "ab-road-north", name: "AB Road approach (north)", - path: [{ lat: lat + 0.03, lng: lng + 0.004 }, { lat: lat + 0.012, lng: lng + 0.001 }, { lat, lng }], + path: [ + { lat: lat + 0.03, lng: lng + 0.004 }, + { lat: lat + 0.012, lng: lng + 0.001 }, + { lat, lng }, + ], }, { id: "ring-road-east", name: "Ring Road (east)", - path: [{ lat: lat + 0.006, lng: lng + 0.03 }, { lat: lat + 0.002, lng: lng + 0.012 }, { lat, lng }], + path: [ + { lat: lat + 0.006, lng: lng + 0.03 }, + { lat: lat + 0.002, lng: lng + 0.012 }, + { lat, lng }, + ], }, { id: "bypass-south", name: "Bypass (south)", - path: [{ lat: lat - 0.028, lng: lng - 0.006 }, { lat: lat - 0.01, lng: lng - 0.002 }, { lat, lng }], + path: [ + { lat: lat - 0.028, lng: lng - 0.006 }, + { lat: lat - 0.01, lng: lng - 0.002 }, + { lat, lng }, + ], }, ]; const LOTS: Omit[] = [ - { id: "lot-a", name: "Lot A (north)", capacity: 3200, location: { lat: lat + 0.006, lng: lng + 0.002 } }, - { id: "lot-b", name: "Lot B (east)", capacity: 2600, location: { lat: lat + 0.001, lng: lng + 0.007 } }, - { id: "lot-c", name: "Lot C (south)", capacity: 2400, location: { lat: lat - 0.006, lng: lng - 0.003 } }, + { + id: "lot-a", + name: "Lot A (north)", + capacity: 3200, + location: { lat: lat + 0.006, lng: lng + 0.002 }, + }, + { + id: "lot-b", + name: "Lot B (east)", + capacity: 2600, + location: { lat: lat + 0.001, lng: lng + 0.007 }, + }, + { + id: "lot-c", + name: "Lot C (south)", + capacity: 2400, + location: { lat: lat - 0.006, lng: lng - 0.003 }, + }, ]; function levelFromLoad(load: number): CongestionLevel { @@ -105,7 +132,8 @@ export function trafficAt(minute: number, phase: MatchPhase, seed = 1): TrafficS if (phase === "fulltime") { advisories.push({ id: `adv-egress-${minute}`, - message: "Egress underway — hold pedestrian crossing priority on Ring Road, stage taxis at Lot B.", + message: + "Egress underway — hold pedestrian crossing priority on Ring Road, stage taxis at Lot B.", severity: "info", }); } diff --git a/src/lib/useMatchStream.test.ts b/src/lib/useMatchStream.test.ts index 073c304..4676178 100644 --- a/src/lib/useMatchStream.test.ts +++ b/src/lib/useMatchStream.test.ts @@ -4,7 +4,12 @@ import type { CrowdDensityEvent, GateFlowEvent, Incident, MatchEvent } from "@/s const INITIAL = dashReducer({} as DashState, { type: "reset" }); -const density = (id: string, zoneId: string, densityPct: number, occupancy = 0): CrowdDensityEvent => ({ +const density = ( + id: string, + zoneId: string, + densityPct: number, + occupancy = 0, +): CrowdDensityEvent => ({ type: "crowd-density", id, venueId: "meridian-arena", @@ -83,17 +88,32 @@ describe("dashReducer", () => { atMinute: 60, phase, }); - const kicked = dashReducer(INITIAL, { type: "message", msg: { kind: "event", event: match("kickoff") } }); + 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") } }); + 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") } }); + 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") } }); + const dup = dashReducer(two, { + type: "message", + msg: { kind: "incident", incident: incident("i2") }, + }); expect(dup.incidents).toHaveLength(2); }); diff --git a/src/lib/useMatchStream.ts b/src/lib/useMatchStream.ts index 4e81cba..232f226 100644 --- a/src/lib/useMatchStream.ts +++ b/src/lib/useMatchStream.ts @@ -69,10 +69,16 @@ 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, CATCHUP_EVENT_COUNT) }; + 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 } }; + next.zones = { + ...state.zones, + [e.zoneId]: { occupancy: e.occupancy, densityPct: e.densityPct }, + }; } if (e.type === "gate-flow") { next.gates = { diff --git a/src/lib/weather/client.ts b/src/lib/weather/client.ts index 19bc91a..1e1db49 100644 --- a/src/lib/weather/client.ts +++ b/src/lib/weather/client.ts @@ -65,14 +65,15 @@ export async function getForecast(): Promise { if (!data.current) throw new Error("Open-Meteo: no current block"); const tempC = data.current.temperature_2m; - const horizon: WeatherHorizon[] = (data.hourly?.time ?? []) - .slice(1, 4) - .map((_, i) => ({ - label: `+${i + 1}h`, - tempC: data.hourly!.temperature_2m[i + 1], - condition: wmoToCondition(data.hourly!.weather_code[i + 1], data.hourly!.temperature_2m[i + 1]), - precipProbPct: data.hourly!.precipitation_probability[i + 1] ?? 0, - })); + const horizon: WeatherHorizon[] = (data.hourly?.time ?? []).slice(1, 4).map((_, i) => ({ + label: `+${i + 1}h`, + tempC: data.hourly!.temperature_2m[i + 1], + condition: wmoToCondition( + data.hourly!.weather_code[i + 1], + data.hourly!.temperature_2m[i + 1], + ), + precipProbPct: data.hourly!.precipitation_probability[i + 1] ?? 0, + })); const base = { source: "live" as const, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 49c3a6e..5e5f143 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -37,10 +37,34 @@ export const DEMO_VENUE: Venue = { city: "Indore", capacity: 52000, zones: [ - { id: "gate-plaza-north", name: "North Gate Plaza", kind: "gate", capacity: 6000, gateIds: ["gate-a", "gate-b"] }, - { id: "gate-plaza-south", name: "South Gate Plaza", kind: "gate", capacity: 6000, gateIds: ["gate-c", "gate-d"] }, - { id: "concourse-north", name: "North Concourse", kind: "concourse", capacity: 9000, gateIds: [] }, - { id: "concourse-south", name: "South Concourse", kind: "concourse", capacity: 9000, gateIds: [] }, + { + id: "gate-plaza-north", + name: "North Gate Plaza", + kind: "gate", + capacity: 6000, + gateIds: ["gate-a", "gate-b"], + }, + { + id: "gate-plaza-south", + name: "South Gate Plaza", + kind: "gate", + capacity: 6000, + gateIds: ["gate-c", "gate-d"], + }, + { + id: "concourse-north", + name: "North Concourse", + kind: "concourse", + capacity: 9000, + gateIds: [], + }, + { + id: "concourse-south", + name: "South Concourse", + kind: "concourse", + capacity: 9000, + gateIds: [], + }, { id: "seating-bowl", name: "Seating Bowl", kind: "seating", capacity: 52000, gateIds: [] }, { id: "medical-bay", name: "Medical Bay", kind: "medical", capacity: 60, gateIds: [] }, ], diff --git a/src/shared/models/events.ts b/src/shared/models/events.ts index c0906b7..af0c462 100644 --- a/src/shared/models/events.ts +++ b/src/shared/models/events.ts @@ -51,12 +51,7 @@ export interface WeatherEvent extends BaseEvent { } export type MatchPhase = - | "gates-open" - | "kickoff" - | "goal" - | "halftime" - | "second-half" - | "fulltime"; + "gates-open" | "kickoff" | "goal" | "halftime" | "second-half" | "fulltime"; export interface MatchEvent extends BaseEvent { type: "match"; @@ -75,11 +70,6 @@ export function nextPhase(current: MatchPhase, event: MatchEvent): MatchPhase { } export type StadiumEvent = - | GateFlowEvent - | CrowdDensityEvent - | RadioLogEvent - | MedicalEvent - | WeatherEvent - | MatchEvent; + GateFlowEvent | CrowdDensityEvent | RadioLogEvent | MedicalEvent | WeatherEvent | MatchEvent; export type StadiumEventType = StadiumEvent["type"]; diff --git a/src/shared/models/incident.ts b/src/shared/models/incident.ts index e2de5dc..e3bec63 100644 --- a/src/shared/models/incident.ts +++ b/src/shared/models/incident.ts @@ -10,12 +10,7 @@ export type IncidentSeverity = "info" | "low" | "medium" | "high" | "critical"; export type IncidentStatus = "open" | "acknowledged" | "resolving" | "resolved"; export type IncidentCategory = - | "crowd" - | "medical" - | "security" - | "weather" - | "facilities" - | "other"; + "crowd" | "medical" | "security" | "weather" | "facilities" | "other"; export interface RecommendedAction { action: string; // e.g. "Open Gate D for overflow entry" diff --git a/src/shared/models/staff.ts b/src/shared/models/staff.ts index 891308c..213c2a2 100644 --- a/src/shared/models/staff.ts +++ b/src/shared/models/staff.ts @@ -35,13 +35,7 @@ export type TaskStatus = "pending" | "acked" | "in-progress" | "done"; /** What generated the task — lets the staff board group by cause. */ export type TaskOrigin = - | "incident" - | "demand" - | "weather" - | "traffic" - | "match" - | "copilot" - | "emergency"; + "incident" | "demand" | "weather" | "traffic" | "match" | "copilot" | "emergency"; export interface StaffTask { id: string; diff --git a/src/shared/models/venue.ts b/src/shared/models/venue.ts index 8f8a428..4b93d2d 100644 --- a/src/shared/models/venue.ts +++ b/src/shared/models/venue.ts @@ -1,12 +1,6 @@ /** Physical layout of a stadium: zones and gates. Shared by the simulator, API, and UI. */ -export type ZoneKind = - | "gate" - | "concourse" - | "seating" - | "concessions" - | "medical" - | "parking"; +export type ZoneKind = "gate" | "concourse" | "seating" | "concessions" | "medical" | "parking"; export interface Zone { id: string; diff --git a/src/shared/models/weather.ts b/src/shared/models/weather.ts index 0ce5cdc..ac6bef5 100644 --- a/src/shared/models/weather.ts +++ b/src/shared/models/weather.ts @@ -1,13 +1,7 @@ /** Weather signals that drive equipment/food demand. Sourced from Open-Meteo * (live) or a simulated fallback — the `source` field says which. */ -export type WeatherCondition = - | "clear" - | "clouds" - | "rain" - | "storm" - | "heat" - | "cold"; +export type WeatherCondition = "clear" | "clouds" | "rain" | "storm" | "heat" | "cold"; export interface WeatherHorizon { /** Human label relative to now, e.g. "+1h", "+2h". */ diff --git a/src/types/google-maps.d.ts b/src/types/google-maps.d.ts new file mode 100644 index 0000000..d3d6345 --- /dev/null +++ b/src/types/google-maps.d.ts @@ -0,0 +1,67 @@ +/** + * Minimal ambient types for the slice of the Google Maps JS API that + * `venue-map.tsx` uses. Lets the integration be typed without pulling in the + * full `@types/google.maps` dependency (the API is loaded at runtime by script + * tag when a Maps key is present). + */ + +interface GMapsLatLng { + lat: number; + lng: number; +} + +/** Opaque map instance handle. */ +interface GMap { + readonly __gmap?: never; +} + +/** Anything that can be attached to / detached from the map. */ +interface GMapOverlay { + setMap(map: GMap | null): void; +} + +interface GMapOptions { + center: GMapsLatLng; + zoom: number; + disableDefaultUI?: boolean; + zoomControl?: boolean; + styles?: unknown[]; +} + +interface GMarkerOptions { + position: GMapsLatLng; + map: GMap; + title?: string; +} + +interface GPolylineOptions { + path: GMapsLatLng[]; + strokeColor?: string; + strokeWeight?: number; + strokeOpacity?: number; + map?: GMap; +} + +interface GCircleOptions { + center: GMapsLatLng; + radius: number; + fillColor?: string; + fillOpacity?: number; + strokeColor?: string; + strokeOpacity?: number; + strokeWeight?: number; + map?: GMap; +} + +interface GoogleMapsApi { + Map: new (el: HTMLElement, opts: GMapOptions) => GMap; + TrafficLayer: new () => GMapOverlay; + Marker: new (opts: GMarkerOptions) => GMapOverlay; + Polyline: new (opts: GPolylineOptions) => GMapOverlay; + Circle: new (opts: GCircleOptions) => GMapOverlay; +} + +interface Window { + google?: { maps: GoogleMapsApi }; + __mdcMapInit?: () => void; +} diff --git a/vitest.config.ts b/vitest.config.ts index c056caf..7f8095e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,13 @@ export default defineConfig({ provider: "v8", include: ["src/lib/**", "src/shared/**"], reporter: ["text", "html"], + // Regression floor for the deterministic core (currently ~77% lines). + thresholds: { + lines: 70, + functions: 70, + branches: 68, + statements: 70, + }, }, }, });