Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
Binary file added docs/screenshots/emergency-evacuation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/ops-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/staff-console.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("; ");
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
140 changes: 140 additions & 0 deletions scripts/e2e-walkthrough.mjs
Original file line number Diff line number Diff line change
@@ -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);
9 changes: 6 additions & 3 deletions src/components/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}

Expand Down
44 changes: 44 additions & 0 deletions src/lib/theme.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading