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
33 changes: 33 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
import type { NextConfig } from "next";

/**
* Content-Security-Policy. Google Maps (loaded at runtime in venue-map.tsx) needs
* maps.googleapis.com for scripts/XHR and the *.gstatic/*.google hosts for tiles;
* Next.js + Tailwind emit inline scripts/styles, hence 'unsafe-inline'. Applied
* only in production so it can't interfere with the dev server's HMR/eval.
*/
const CSP = [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
"img-src 'self' data: blob: https://*.googleapis.com https://*.gstatic.com https://*.google.com",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com",
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"connect-src 'self' https://maps.googleapis.com https://*.googleapis.com",
"worker-src 'self' blob:",
].join("; ");

const securityHeaders = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), geolocation=(), microphone=(self)" },
...(process.env.NODE_ENV === "production"
? [{ key: "Content-Security-Policy", value: CSP }]
: []),
];

const nextConfig: NextConfig = {
// Standalone output produces a self-contained server.js for the Cloud Run Dockerfile.
output: "standalone",
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
};

export default nextConfig;
4 changes: 4 additions & 0 deletions src/app/api/briefing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export async function POST(req: NextRequest) {
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Normalize the client-supplied collections so a malformed body can't throw a
// TypeError mid-handler (which would surface as a misleading Gemini-shaped 500).
body.incidents = Array.isArray(body.incidents) ? body.incidents : [];
body.recentEvents = Array.isArray(body.recentEvents) ? body.recentEvents : [];
const isHandover = req.nextUrl.searchParams.get("type") === "handover";

try {
Expand Down
17 changes: 6 additions & 11 deletions src/app/api/copilot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server";
import { Type, type FunctionDeclaration } from "@google/genai";
import { geminiErrorMessage, getClient, getModel } from "@/lib/gemini";
import { DEMO_VENUE } from "@/shared/constants";
import { STAFF_ROLES } from "@/shared/models";

export const dynamic = "force-dynamic";

Expand All @@ -12,6 +13,9 @@ export const dynamic = "force-dynamic";
* tool calls are returned to the client, which executes them locally.
*/

/** Most recent chat turns sent to the model — bounds prompt size and cost. */
const MAX_HISTORY = 12;

interface ChatMessage {
role: "user" | "assistant";
text: string;
Expand Down Expand Up @@ -53,16 +57,7 @@ const TOOL_DECLARATIONS: FunctionDeclaration[] = [
properties: {
role: {
type: Type.STRING,
enum: [
"catering",
"concessions",
"stewarding",
"security",
"medical",
"facilities",
"traffic",
"logistics",
],
enum: [...STAFF_ROLES],
},
title: { type: Type.STRING },
detail: { type: Type.STRING, description: "Specific, actionable instruction" },
Expand Down Expand Up @@ -99,7 +94,7 @@ export async function POST(req: NextRequest) {
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const history = (body.messages ?? []).slice(-12).filter((m) => m.text?.trim());
const history = (body.messages ?? []).slice(-MAX_HISTORY).filter((m) => m.text?.trim());
if (history.length === 0 || history[history.length - 1].role !== "user") {
return NextResponse.json({ error: "Last message must be from the user" }, { status: 400 });
}
Expand Down
53 changes: 18 additions & 35 deletions src/app/api/demand/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,19 @@ import { NextResponse, type NextRequest } from "next/server";
import { Type } from "@google/genai";
import { geminiErrorMessage, generateJson } from "@/lib/gemini";
import { DEMO_VENUE } from "@/shared/constants";
import type {
DemandLine,
DemandPlan,
StaffRole,
StaffTask,
SupplyItem,
WeatherForecast,
import {
clampPriority,
STAFF_ROLES,
SUPPLY_ITEMS,
type DemandLine,
type DemandPlan,
type StaffTask,
type SupplyItem,
type WeatherForecast,
} from "@/shared/models";

export const dynamic = "force-dynamic";

const SUPPLY_ITEMS: SupplyItem[] = [
"umbrella",
"poncho",
"bottled-water",
"cold-drink",
"hot-beverage",
"hot-food",
"cold-food",
"handheld-fan",
"blanket",
"ice",
"energy-drink",
];

const STAFF_ROLES: StaffRole[] = [
"catering",
"concessions",
"stewarding",
"security",
"medical",
"facilities",
"traffic",
"logistics",
];

/** Opening stock on hand, so the model computes real shortfalls (gap). */
const DEFAULT_STOCK: Record<SupplyItem, number> = {
umbrella: 200,
Expand Down Expand Up @@ -71,7 +48,7 @@ const DEMAND_SCHEMA = {
items: {
type: Type.OBJECT,
properties: {
item: { type: Type.STRING, enum: SUPPLY_ITEMS },
item: { type: Type.STRING, enum: [...SUPPLY_ITEMS] },
predictedUnits: { type: Type.INTEGER },
currentStock: { type: Type.INTEGER },
driver: { type: Type.STRING },
Expand All @@ -85,7 +62,7 @@ const DEMAND_SCHEMA = {
items: {
type: Type.OBJECT,
properties: {
role: { type: Type.STRING, enum: STAFF_ROLES },
role: { type: Type.STRING, enum: [...STAFF_ROLES] },
title: { type: Type.STRING },
detail: { type: Type.STRING },
priority: { type: Type.INTEGER },
Expand Down Expand Up @@ -116,10 +93,15 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const attendance = Number(body.attendance);
if (!Number.isFinite(attendance)) {
return NextResponse.json({ error: "attendance must be a number" }, { status: 400 });
}

const stock = { ...DEFAULT_STOCK, ...(body.currentStock ?? {}) };
const prompt = [
`Match clock: minute ${body.minute} (kickoff at 60), phase ${body.phase}.`,
`Expected attendance: ${body.attendance.toLocaleString()}.`,
`Expected attendance: ${attendance.toLocaleString()}.`,
"",
"Weather forecast:",
JSON.stringify(body.weather, null, 2),
Expand All @@ -144,6 +126,7 @@ export async function POST(req: NextRequest) {
};
const tasks: StaffTask[] = gen.prepTasks.map((t, i) => ({
...t,
priority: clampPriority(t.priority),
id: `task-dmd-${body.minute}-${i}`,
createdAtMinute: body.minute,
origin: "demand",
Expand Down
26 changes: 12 additions & 14 deletions src/app/api/emergency/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import { Type } from "@google/genai";
import { geminiErrorMessage, generateJson } from "@/lib/gemini";
import { addTask } from "@/lib/store";
import { DEMO_VENUE } from "@/shared/constants";
import type { EvacuationPlan, EvacZoneOrder, Incident, StaffRole, StaffTask } from "@/shared/models";
import {
clampPriority,
STAFF_ROLES,
type EvacuationPlan,
type EvacZoneOrder,
type Incident,
type StaffRole,
type StaffTask,
} from "@/shared/models";

export const dynamic = "force-dynamic";

Expand All @@ -21,16 +29,6 @@ interface EmergencyRequest {
}

const ZONE_IDS = DEMO_VENUE.zones.map((z) => z.id);
const STAFF_ROLES: StaffRole[] = [
"catering",
"concessions",
"stewarding",
"security",
"medical",
"facilities",
"traffic",
"logistics",
];

const EVAC_SCHEMA = {
type: Type.OBJECT,
Expand All @@ -55,7 +53,7 @@ const EVAC_SCHEMA = {
items: {
type: Type.OBJECT,
properties: {
role: { type: Type.STRING, enum: STAFF_ROLES },
role: { type: Type.STRING, enum: [...STAFF_ROLES] },
title: { type: Type.STRING },
detail: { type: Type.STRING },
priority: { type: Type.INTEGER },
Expand Down Expand Up @@ -110,7 +108,7 @@ export async function POST(req: NextRequest) {
commandSummary: gen.commandSummary,
zoneOrders: gen.zoneOrders.map((o) => ({
...o,
priority: Math.min(3, Math.max(1, Math.round(o.priority))) as 1 | 2 | 3,
priority: clampPriority(o.priority),
zoneName: DEMO_VENUE.zones.find((z) => z.id === o.zoneId)?.name ?? o.zoneId,
})),
};
Expand All @@ -120,7 +118,7 @@ export async function POST(req: NextRequest) {
role: t.role,
title: `[EVAC] ${t.title}`,
detail: t.detail,
priority: Math.min(3, Math.max(1, Math.round(t.priority))) as 1 | 2 | 3,
priority: clampPriority(t.priority),
origin: "emergency",
createdAtMinute: body.minute,
dueBy: "immediately",
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/sim/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export async function POST(req: NextRequest) {
if (body.type !== "speed" && body.type !== "jump" && body.type !== "restart") {
return NextResponse.json({ error: "Unknown control type" }, { status: 400 });
}
// Guard the numeric payloads: a non-finite tickMs/toMinute would otherwise
// reach the clamp as NaN and feed setInterval(NaN) / an infinite jump loop.
if (body.type === "speed" && !Number.isFinite(body.tickMs)) {
return NextResponse.json({ error: "tickMs must be a finite number" }, { status: 400 });
}
if (body.type === "jump" && !Number.isFinite(body.toMinute)) {
return NextResponse.json({ error: "toMinute must be a finite number" }, { status: 400 });
}
const live = getLiveMatch();
live.control(body);
return NextResponse.json({ ok: true, minute: live.minute });
Expand Down
24 changes: 11 additions & 13 deletions src/app/api/tasks/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
import { NextResponse, type NextRequest } from "next/server";
import { addTask, listTasks } from "@/lib/store";
import type { StaffRole, TaskOrigin } from "@/shared/models";
import { clampPriority, STAFF_ROLES, type StaffRole, type TaskOrigin } from "@/shared/models";

export const dynamic = "force-dynamic";

const ROLES: StaffRole[] = [
"catering",
"concessions",
"stewarding",
"security",
"medical",
"facilities",
"traffic",
"logistics",
];
/** Reject oversized free-text so the in-memory store can't be bloated. */
const MAX_FIELD_LEN = 500;

const ORIGINS: TaskOrigin[] = [
"incident",
Expand Down Expand Up @@ -47,10 +39,16 @@ export async function POST(req: NextRequest) {
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
if (!ROLES.includes(body.role) || !body.title || !body.detail) {
if (!STAFF_ROLES.includes(body.role) || !body.title || !body.detail) {
return NextResponse.json({ error: "role, title, and detail are required" }, { status: 400 });
}
const priority = Math.min(3, Math.max(1, Math.round(body.priority ?? 2))) as 1 | 2 | 3;
if (body.title.length > MAX_FIELD_LEN || body.detail.length > MAX_FIELD_LEN) {
return NextResponse.json(
{ error: `title and detail must be under ${MAX_FIELD_LEN} characters` },
{ status: 400 },
);
}
const priority = clampPriority(body.priority ?? 2);
const task = addTask({
role: body.role,
title: body.title,
Expand Down
13 changes: 13 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,16 @@ body {
background: #0d0d0d;
color: #c3c2b7;
}

/* Respect users who ask the OS to minimize motion: the density/queue bars
animate their width on every telemetry tick, which this disables. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
7 changes: 6 additions & 1 deletion src/components/copilot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ export function Copilot({
<p className="text-[11px] font-semibold uppercase tracking-wider" style={{ color: ACCENT }}>
✦ Control-room copilot
</p>
<button onClick={() => setOpen(false)} className="text-sm text-[#898781] hover:text-white">
<button
onClick={() => setOpen(false)}
aria-label="Close copilot"
className="text-sm text-[#898781] hover:text-white"
>
</button>
</div>
Expand Down Expand Up @@ -171,6 +175,7 @@ export function Copilot({
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask or command…"
aria-label="Message the control-room copilot"
className="min-w-0 flex-1 rounded border border-white/10 bg-[#0d0d0d] px-2.5 py-1.5 text-sm text-white placeholder:text-[#898781] focus:outline-none"
/>
<button
Expand Down
Loading
Loading