Gate A (BUSINESS_MODEL.md, board minutes #42, dated to today by minutes #79 decision 14) blocks putting a price or an unbuilt entitlement claim on a surface a reader can see, and requires that unverified machine assertions be labelled as such on the same screen as the assertion. The pricing leg was already met — /pricing and both homepage CTAs stopped quoting the invented catalog. Condition A2 was not: a search of the whole frontend for a "machine-generated" or "unverified" qualifier returned zero hits. Every transcript, summary, title, location, unit list and vehicle list is pipeline output that no human reviews, and entity-name accuracy in those transcripts has never been measured (server-26#48) — yet all of it was rendered to the reader as plain fact. Unqualified machine assertions about real incidents and real people is the exposure Gate A exists to stop. A2 — one reusable element, components/ui/MachineOutputNotice.tsx, rendered on the same screen as the output (a footnote elsewhere does not satisfy A1's "same screen" standard). Three variants for three shapes of surface, all saying the same thing; the "popup" variant uses fixed grays because a Leaflet popup is stock-white in both themes. Covered: - incident detail: under the summary (covers summary, title, location, units on scene/cleared, vehicles, tags) and above the call spine - incident list: above the timeline groups - Archive (/calls): above the transcript rows - node detail: above the Recent Calls table - Watch//alerts: above the events table, whose Snippet column is transcript text and whose keyword match was made against it - Live map: the desktop incident rail, pinned above the scroll area so it cannot be scrolled off the screen it qualifies; the mobile drawer; the incident marker popup; the incident-path stop popup - /systems: the source-call transcript preview - /features: the two marketing sections that describe the AI pipeline A1 — components/ui/UnbuiltMarker.tsx marks a claim unbuilt inline: - /faq: the retention answer promised 7/90/365-day windows. There is no TTL and no deletion sweep anywhere in the product (server-26#44), so the answer now states plainly that nothing is deleted automatically and marks per-plan retention as not yet available. - /settings/billing: the plan cards' claims — custom retention, SSO/SAML, uptime SLA, data residency — are marked not-yet-available next to the plan that makes them. Labelling only. No retention, SSO, SLA or residency was built; no billing, Stripe or checkout code was touched (Gate B still bars charging anyone); no price was added anywhere; no Python was touched. Both themes verified against the light-mode !important overrides in globals.css, which are untouched. tsc --noEmit clean. Refs: server-26#46, server-26#44, server-26#48 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
import { useIncidents } from "@/lib/useIncidents";
|
|
import { useActiveCalls } from "@/lib/useCalls";
|
|
import { c2api } from "@/lib/c2api";
|
|
import type { IncidentRecord } from "@/lib/types";
|
|
import { PageHeader } from "@/components/ui/PageHeader";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Badge } from "@/components/ui/Badge";
|
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
|
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
|
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
|
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
|
|
|
type SeverityFilter = "all" | "minor" | "moderate" | "major";
|
|
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
|
|
{ key: "all", label: "All" },
|
|
{ key: "minor", label: "Minor+" },
|
|
{ key: "moderate", label: "Moderate+" },
|
|
{ key: "major", label: "Major only" },
|
|
];
|
|
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
|
|
|
|
type SortMode = "recent" | "severity";
|
|
|
|
function fmtTime(iso: string) {
|
|
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
|
}
|
|
|
|
function dayBucket(iso: string): string {
|
|
const d = new Date(iso);
|
|
const now = new Date();
|
|
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
const diffDays = Math.round((startOfDay(now) - startOfDay(d)) / 86_400_000);
|
|
if (diffDays === 0) return "Today";
|
|
if (diffDays === 1) return "Yesterday";
|
|
return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" });
|
|
}
|
|
|
|
function timeAgo(iso: string): string {
|
|
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
if (s < 60) return `${s}s ago`;
|
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
return `${Math.floor(s / 86400)}d ago`;
|
|
}
|
|
|
|
// Same rail-card anatomy as Live (MapView's incident panel), at browse
|
|
// density: severity spine, type glyph, severity chip, ON AIR pill, title,
|
|
// location, units-on-scene chips, age + call count. UI_REDESIGN.md §5.1/§5.2
|
|
// — the point is that Live and Incidents read as the same object.
|
|
function IncidentBrowseRow({
|
|
incident,
|
|
isAdmin,
|
|
onAir,
|
|
onResolve,
|
|
}: {
|
|
incident: IncidentRecord;
|
|
isAdmin: boolean;
|
|
onAir: boolean;
|
|
onResolve: (id: string) => void;
|
|
}) {
|
|
const router = useRouter();
|
|
const sev = isKnownSeverity(incident.severity) ? incident.severity : "routine";
|
|
const units = incident.units_active ?? incident.units ?? [];
|
|
|
|
return (
|
|
<div
|
|
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-raised cursor-pointer transition-colors items-stretch"
|
|
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
|
>
|
|
<SeveritySpine severity={sev} />
|
|
<TypeGlyph type={incident.type} size={20} className="text-ink-2 mt-0.5 shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<SeverityMark severity={sev} showLabel />
|
|
{onAir && (
|
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-sev-major/15 text-sev-major uppercase tracking-wide">
|
|
On air
|
|
</span>
|
|
)}
|
|
<Badge tone={incident.status === "active" ? "brand" : "neutral"}>{incident.status}</Badge>
|
|
</div>
|
|
<p className="text-ink text-sm font-semibold leading-snug mt-0.5 truncate">{incident.title ?? "Incident"}</p>
|
|
{incident.location && <p className="text-ink-muted text-xs mt-0.5 truncate">{incident.location}</p>}
|
|
<div className="flex items-center gap-2 flex-wrap mt-1">
|
|
{units.slice(0, 4).map((u) => (
|
|
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-raised text-ink-2">{u}</span>
|
|
))}
|
|
<span className="text-xs text-ink-muted font-mono ml-auto">
|
|
{fmtTime(incident.started_at)} · {timeAgo(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{isAdmin && incident.status === "active" && (
|
|
<Button
|
|
size="sm" variant="secondary"
|
|
className="self-center shrink-0"
|
|
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
|
>
|
|
Resolve
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
|
|
const [title, setTitle] = useState("");
|
|
const [type, setType] = useState("other");
|
|
const [summary, setSummary] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
try {
|
|
await onCreate({ title, type, summary: summary || null, status: "active" });
|
|
onClose();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
|
<form onSubmit={handleSubmit} className="bg-surface border border-line rounded-xl p-6 w-full max-w-md space-y-4">
|
|
<h2 className="text-ink font-semibold">Create Incident</h2>
|
|
<div>
|
|
<label className="text-xs text-ink-muted block mb-1">Title</label>
|
|
<input
|
|
required value={title} onChange={(e) => setTitle(e.target.value)}
|
|
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-ink-muted block mb-1">Type</label>
|
|
<select
|
|
value={type} onChange={(e) => setType(e.target.value)}
|
|
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none"
|
|
>
|
|
{["fire", "police", "ems", "accident", "other"].map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-ink-muted block mb-1">Summary (optional)</label>
|
|
<textarea
|
|
value={summary} onChange={(e) => setSummary(e.target.value)} rows={2}
|
|
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none resize-none"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 justify-end">
|
|
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
|
|
<Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function IncidentsPage() {
|
|
const { isAdmin } = useAuth();
|
|
const { incidents, loading, error } = useIncidents();
|
|
const activeCalls = useActiveCalls();
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
|
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
|
|
|
const onAirIncidentIds = useMemo(() => {
|
|
const s = new Set<string>();
|
|
for (const c of activeCalls) {
|
|
for (const id of c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : []) s.add(id);
|
|
}
|
|
return s;
|
|
}, [activeCalls]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const threshold = FILTER_THRESHOLD[severityFilter];
|
|
const list = incidents.filter((i) => severityRank(i.severity) >= threshold);
|
|
if (sortMode === "severity") {
|
|
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
|
|
}
|
|
return list; // useIncidents() already orders by started_at desc
|
|
}, [incidents, severityFilter, sortMode]);
|
|
|
|
const hiddenCount = incidents.length - filtered.length;
|
|
const activeCount = filtered.filter((i) => i.status === "active").length;
|
|
|
|
// Timeline grouping (Today / Yesterday / date) replaces the old
|
|
// active/resolved two-table split — status is now a chip on the row, not a
|
|
// section boundary, so an active and a resolved incident from the same
|
|
// evening read as what they are: the same kind of object.
|
|
const groups = useMemo(() => {
|
|
const byDay = new Map<string, IncidentRecord[]>();
|
|
for (const inc of filtered) {
|
|
const key = dayBucket(inc.started_at);
|
|
if (!byDay.has(key)) byDay.set(key, []);
|
|
byDay.get(key)!.push(inc);
|
|
}
|
|
return byDay;
|
|
}, [filtered]);
|
|
|
|
async function handleResolve(id: string) {
|
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
|
catch (e) { console.error(e); }
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="Incidents"
|
|
badge={activeCount > 0 && <Badge tone="danger">{activeCount} active</Badge>}
|
|
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
|
|
/>
|
|
|
|
{/* Gate A / A2 (server-26#46) — every row's title, location and unit
|
|
chips are pipeline output, so the notice rides with the list. */}
|
|
<MachineOutputNotice variant="inline" />
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
|
{SEVERITY_FILTERS.map(({ key, label }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setSeverityFilter(key)}
|
|
className={`text-sm px-3.5 py-1.5 rounded-md transition-colors ${
|
|
severityFilter === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<label className="flex items-center gap-2 text-xs text-ink-muted">
|
|
Sort
|
|
<select
|
|
value={sortMode}
|
|
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
|
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-ink-2 focus:outline-none focus:border-accent"
|
|
>
|
|
<option value="recent">Most recent</option>
|
|
<option value="severity">Highest severity</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<SkeletonCard /><SkeletonCard />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{hiddenCount > 0 && (
|
|
<p className="text-xs text-ink-muted">
|
|
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
|
|
</p>
|
|
)}
|
|
|
|
{Array.from(groups.entries()).map(([day, incs]) => (
|
|
<section key={day}>
|
|
<h2 className="text-sm text-ink-muted font-medium mb-1">{day}</h2>
|
|
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
|
{incs.map((inc) => (
|
|
<IncidentBrowseRow
|
|
key={inc.incident_id}
|
|
incident={inc}
|
|
isAdmin={isAdmin}
|
|
onAir={onAirIncidentIds.has(inc.incident_id)}
|
|
onResolve={handleResolve}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
|
|
{/* An empty list is only news when the query actually succeeded.
|
|
A failed Firestore query (missing composite index, denied rules)
|
|
also leaves `incidents` empty, and rendering "no incidents
|
|
recorded yet" over the top of it told the operator the radio was
|
|
quiet when the page had simply failed to load — server-26#13. */}
|
|
{filtered.length === 0 && error && (
|
|
<ErrorBanner message={`Couldn't load incidents: ${error}`} />
|
|
)}
|
|
|
|
{filtered.length === 0 && !error && (
|
|
<EmptyState
|
|
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
|
|
description={
|
|
incidents.length === 0
|
|
? "Incidents appear automatically once calls start correlating."
|
|
: "Try a lower severity threshold."
|
|
}
|
|
action={
|
|
incidents.length > 0 && severityFilter !== "all" ? (
|
|
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<CreateModal onClose={() => setShowCreate(false)} onCreate={async (b) => { await c2api.createIncident(b); }} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|