diff --git a/README.md b/README.md index 7106ad5..e619ec8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,16 @@ Running 104 World Cup matches across 16 stadiums pushed real tournaments toward The app has **three views on one shared live match**: the **ops control room** (`/`), the **staff console** (`/staff`), and the **tournament supervisor** (`/tournament`). One server-side simulation broadcasts to every client — all screens show the same clock and story, and sim controls (speed / next-beat / restart) are global. +## Screenshots + +| Ops control room | Emergency evacuation mode | +|---|---| +| ![Ops control room — zone density, live Google-traffic map, incident queue with AI triage](docs/screenshots/ops-dashboard.png) | ![Emergency mode — AI-written evacuation plan with PA announcement and sequenced zone orders](docs/screenshots/emergency-evacuation.png) | + +The staff console (`/staff`) — a role-grouped task board fed by incidents, weather, traffic, and the AI demand plan: + +![Staff console task board](docs/screenshots/staff-console.png) + ## What it does 1. **Live signal feed** — a match-day simulator streams gate throughput, crowd density, steward radio logs, medical calls, and weather over SSE. (Simulated data means the demo never depends on hardware or third-party APIs.) diff --git a/docs/screenshots/emergency-evacuation.png b/docs/screenshots/emergency-evacuation.png new file mode 100644 index 0000000..6229853 Binary files /dev/null and b/docs/screenshots/emergency-evacuation.png differ diff --git a/docs/screenshots/ops-dashboard.png b/docs/screenshots/ops-dashboard.png new file mode 100644 index 0000000..44ae92b Binary files /dev/null and b/docs/screenshots/ops-dashboard.png differ diff --git a/docs/screenshots/staff-console.png b/docs/screenshots/staff-console.png new file mode 100644 index 0000000..57905f5 Binary files /dev/null and b/docs/screenshots/staff-console.png differ diff --git a/next.config.ts b/next.config.ts index 628fa37..4f0df61 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,8 +14,8 @@ const CSP = [ "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:", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' data: https://fonts.gstatic.com", "connect-src 'self' https://maps.googleapis.com https://*.googleapis.com", "worker-src 'self' blob:", ].join("; "); diff --git a/package.json b/package.json index b704c60..3db9e99 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "format:check": "prettier --check .", "typecheck": "tsc --noEmit", "audit:a11y": "node scripts/a11y-audit.mjs", + "e2e": "node scripts/e2e-walkthrough.mjs", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" diff --git a/scripts/e2e-walkthrough.mjs b/scripts/e2e-walkthrough.mjs new file mode 100644 index 0000000..0bd6c66 --- /dev/null +++ b/scripts/e2e-walkthrough.mjs @@ -0,0 +1,140 @@ +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.BASE_URL || "http://localhost:3000"; +const OUT = process.env.E2E_OUT || "./e2e-out"; +mkdirSync(OUT, { recursive: true }); + +const results = []; +const consoleErrors = []; +const step = async (name, fn) => { + try { + await fn(); + results.push(`PASS ${name}`); + } catch (err) { + results.push(`FAIL ${name} — ${err.message.split("\n")[0]}`); + } +}; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +page.on("console", (m) => { + if (m.type() === "error") consoleErrors.push(m.text()); +}); +page.on("pageerror", (e) => consoleErrors.push(`pageerror: ${e.message}`)); + +const shot = (n) => page.screenshot({ path: `${OUT}/${n}.png` }); + +await page.goto(BASE + "/", { waitUntil: "domcontentloaded", timeout: 30000 }); +await page.waitForTimeout(2500); + +// Advance the sim to generate telemetry + incidents. +await step("fast-forward sim to build state", async () => { + for (let i = 0; i < 6; i++) { + const beat = page.getByRole("button", { name: /Next beat/i }); + if (await beat.isEnabled().catch(() => false)) { + await beat.click(); + await page.waitForTimeout(900); + } + } + await shot("01-ops-populated"); +}); + +await step("live feed shows events", async () => { + const feedText = await page.getByRole("region", { name: /live event feed/i }).innerText(); + if (feedText.trim().length < 5) throw new Error("feed empty"); +}); + +await step("AI triage an incident", async () => { + const triageBtn = page.getByRole("button", { name: /^AI triage$/ }).first(); + if ((await triageBtn.count()) === 0) { + results.push("SKIP AI triage — no open incident to triage"); + return; + } + await triageBtn.click(); + await page.getByText("AI triage", { exact: false }).first().waitFor({ timeout: 45000 }); + await page.waitForTimeout(1500); + await shot("02-triage"); +}); + +await step("generate ops briefing", async () => { + await page.getByRole("button", { name: "Ops briefing" }).click(); + await page.getByRole("dialog").waitFor({ timeout: 5000 }); + // Wait for generation to finish (loading label gone) before closing. + await page + .getByText(/Writing ops briefing/i) + .waitFor({ state: "hidden", timeout: 45000 }) + .catch(() => {}); + await page.waitForTimeout(500); + await shot("03-briefing"); + await page.getByRole("button", { name: "Close" }).click(); + // Confirm the overlay actually closed (regression guard for the reopen bug). + await page.getByRole("dialog").waitFor({ state: "hidden", timeout: 8000 }); +}); + +await step("copilot answers a question", async () => { + await page.getByRole("button", { name: /Copilot/i }).click(); + const input = page.getByRole("textbox", { name: /copilot/i }); + await input.fill("What needs my attention right now?"); + await page.getByRole("button", { name: "Send" }).click(); + await page.waitForTimeout(8000); // Gemini round-trip (+ possible fallback) + await shot("04-copilot"); +}); + +await step("emergency evacuation flow", async () => { + // Close the copilot first so its panel can't intercept clicks. + await page + .getByRole("button", { name: /close copilot/i }) + .click() + .catch(() => {}); + await page.getByRole("button", { name: /Emergency/i }).click(); + await page.getByRole("dialog", { name: /major incident/i }).waitFor({ timeout: 10000 }); + // Activate; retry once if the AI call transiently 500s (a real operator would too). + let planShown = false; + for (let attempt = 0; attempt < 2 && !planShown; attempt++) { + await page + .getByRole("button", { name: /Activate emergency/i }) + .click() + .catch(() => {}); + try { + await page.getByRole("dialog", { name: /evacuation plan/i }).waitFor({ timeout: 45000 }); + planShown = true; + } catch { + await page + .getByRole("button", { name: "Close" }) + .click() + .catch(() => {}); + await page + .getByRole("button", { name: /Emergency/i }) + .click() + .catch(() => {}); + } + } + if (!planShown) throw new Error("evacuation plan did not appear after retry"); + await shot("05-evacuation"); + await page.getByRole("button", { name: "Close" }).click(); +}); + +await step("staff console renders", async () => { + await page.goto(BASE + "/staff", { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(3000); + await page.getByText(/Task board/i).waitFor({ timeout: 10000 }); + await shot("06-staff"); +}); + +await step("tournament view renders", async () => { + await page.goto(BASE + "/tournament", { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2500); + await page.getByText(/Tournament pulse/i).waitFor({ timeout: 10000 }); + await shot("07-tournament"); +}); + +await browser.close(); + +console.log("\n=== E2E walkthrough ==="); +for (const r of results) console.log(r); +console.log(`\n=== console/page errors: ${consoleErrors.length} ===`); +for (const e of consoleErrors.slice(0, 20)) console.log(` - ${e}`); +const failed = results.filter((r) => r.startsWith("FAIL")).length; +console.log(`\n${failed} failed, screenshots in ${OUT}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index aaf06c8..408bdf8 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -175,15 +175,18 @@ export default function Dashboard() { }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "Generation failed"); + // Only surface the result if the operator hasn't closed the overlay (or + // opened something else) while the doc was being written. if (type === "briefing") { setBriefing(data as OpsBriefing); - setDoc({ kind: "briefing", data }); + setDoc((prev) => (prev?.kind === "loading" ? { kind: "briefing", data } : prev)); } else { setHandover(data as HandoverReport); - setDoc({ kind: "handover", data }); + setDoc((prev) => (prev?.kind === "loading" ? { kind: "handover", data } : prev)); } } catch (err) { - setDoc({ kind: "error", message: err instanceof Error ? err.message : "Generation failed" }); + const message = err instanceof Error ? err.message : "Generation failed"; + setDoc((prev) => (prev?.kind === "loading" ? { kind: "error", message } : prev)); } } diff --git a/src/lib/theme.test.ts b/src/lib/theme.test.ts new file mode 100644 index 0000000..93cf25e --- /dev/null +++ b/src/lib/theme.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + CONGESTION_COLOR, + CRITICAL_TEXT, + PHASE_LABEL, + SEVERITY_META, + STATUS, + densityState, +} from "./theme"; + +describe("densityState", () => { + it("labels each density band by threshold", () => { + expect(densityState(95).label).toBe("Critical"); + expect(densityState(88).label).toBe("Alert"); + expect(densityState(75).label).toBe("Watch"); + expect(densityState(40).label).toBe("OK"); + }); + + it("uses the accessible critical-text color for critical density", () => { + expect(densityState(95).color).toBe(CRITICAL_TEXT); + expect(densityState(40).color).toBe(STATUS.good); + }); +}); + +describe("PHASE_LABEL", () => { + it("maps the transient goal phase to the same label as kickoff", () => { + expect(PHASE_LABEL.goal).toBe(PHASE_LABEL.kickoff); + }); +}); + +describe("SEVERITY_META", () => { + it("covers every severity with a non-empty label and a color", () => { + for (const sev of ["info", "low", "medium", "high", "critical"] as const) { + expect(SEVERITY_META[sev].label.length).toBeGreaterThan(0); + expect(SEVERITY_META[sev].color).toMatch(/^#/); + } + }); +}); + +describe("CONGESTION_COLOR", () => { + it("maps all four congestion levels", () => { + expect(Object.keys(CONGESTION_COLOR).sort()).toEqual(["clear", "heavy", "moderate", "severe"]); + }); +});