Files
server-26/drb-frontend/app/incidents/[id]/page.tsx
T
Logan CusanoandClaude Opus 5 b7222230bd
Build & Deploy / Build & push images (push) Successful in 4m25s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
Build & Deploy / Report a failed deploy (push) Skipped
frontend: label machine-generated output and unbuilt entitlements (Gate A)
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>
2026-08-31 02:47:40 -04:00

264 lines
10 KiB
TypeScript

"use client";
import dynamic from "next/dynamic";
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useIncident } from "@/lib/useIncidents";
import { useCallsByIncident } from "@/lib/useCalls";
import { useAuth } from "@/components/AuthProvider";
import { CallSpineEntry } from "@/components/CallSpineEntry";
import { c2api } from "@/lib/c2api";
import { TypeGlyph } from "@/components/marks/TypeGlyph";
import { SeverityMark } from "@/components/marks/SeverityMark";
import { isKnownSeverity } from "@/lib/severity";
import { Button } from "@/components/ui/Button";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import type { CallRecord } from "@/lib/types";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const EARLIER_PAGE_SIZE = 8;
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
const start = new Date(startedAt).getTime();
const end = active ? Date.now() : new Date(updatedAt).getTime();
const mins = Math.max(0, Math.round((end - start) / 60000));
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
return `${hrs}h ${mins % 60}m`;
}
export default function IncidentDetailPage() {
const params = useParams();
const id = params.id as string;
const router = useRouter();
const { incident, loading } = useIncident(id);
const { calls, loading: callsLoading } = useCallsByIncident(id);
const { isAdmin } = useAuth();
const [summarizing, setSummarizing] = useState(false);
const [resolving, setResolving] = useState(false);
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
// Same ordering/filtering MapView's IncidentPathLayer uses, so the stop
// number shown on a spine entry matches the number on its map marker.
const geocodedCalls = useMemo(
() =>
calls
.filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at)),
[calls]
);
const stopNumberByCallId = useMemo(() => {
const m = new Map<string, number>();
geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1));
return m;
}, [geocodedCalls]);
const pathLengthKm = useMemo(() => {
let total = 0;
for (let i = 1; i < geocodedCalls.length; i++) {
total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!);
}
return total;
}, [geocodedCalls]);
const newestFirst = useMemo(
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
[calls]
);
async function handleResolve() {
setResolving(true);
try { await c2api.updateIncident(id, { status: "resolved" }); }
catch (e) { console.error(e); }
finally { setResolving(false); }
}
async function handleSummarize() {
setSummarizing(true);
try { await c2api.summarizeIncident(id); }
catch (e) { console.error(e); }
finally { setSummarizing(false); }
}
if (loading) return <p className="text-ink-muted text-sm p-6">Loading…</p>;
if (!incident) return <p className="text-ink-muted text-sm p-6">Incident not found.</p>;
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
const unitsActive = incident.units_active ?? incident.units ?? [];
const unitsCleared = incident.units_cleared ?? [];
const active = incident.status === "active";
const visible = newestFirst.slice(0, earlierShown);
const remaining = newestFirst.length - visible.length;
return (
<div className="space-y-4">
<button
onClick={() => router.back()}
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
>
← Incidents
</button>
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex flex-col gap-1.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<TypeGlyph type={incident.type} size={20} className="text-ink-2" />
{isKnownSeverity(incident.severity) && <SeverityMark severity={incident.severity} showLabel size="md" />}
<span className={`text-xs px-2 py-0.5 rounded-full ${active ? "bg-accent/15 text-accent" : "bg-raised text-ink-2"}`}>
{active ? "Active" : "Resolved"}
</span>
</div>
<h1 className="text-[27px] font-semibold text-ink leading-tight">
{incident.title ?? "Incident"}
</h1>
<p className="text-xs text-ink-muted font-mono">
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
{pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path</>}
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</p>
</div>
{isAdmin && (
<div className="flex gap-2 shrink-0 flex-wrap">
<Button variant="secondary" size="sm" onClick={handleSummarize} disabled={summarizing}>
{summarizing ? "Generating…" : "Regenerate summary"}
</Button>
{active && (
<Button variant="secondary" size="sm" onClick={handleResolve} disabled={resolving}>
{resolving ? "Resolving…" : "Mark resolved"}
</Button>
)}
</div>
)}
</div>
{displayTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{displayTags.map((t) => (
<span key={t} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded-full">{t}</span>
))}
</div>
)}
{/* Two columns: 828 / 612 per UI_REDESIGN.md §5.2 */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
{/* Left */}
<div className="lg:col-span-3 space-y-4">
{incident.location_coords && (
<div style={{ height: "352px" }} className="rounded-xl overflow-hidden border border-line">
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} calls={calls} />
</div>
)}
{/* Summary — first, in prose. Not a tab. */}
<div className="space-y-2.5">
{incident.summary ? (
<p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p>
) : (
<p className="text-sm text-ink-muted italic">
No summary yet.{" "}
{isAdmin && (
<button onClick={handleSummarize} disabled={summarizing} className="text-accent not-italic hover:underline">
Generate now
</button>
)}
</p>
)}
{/* Gate A / A2 (server-26#46): the summary, the title, the location,
the units and the vehicles below are ALL pipeline output, so the
notice sits on this screen with them — not on a policy page. */}
<MachineOutputNotice />
</div>
{/* On scene / Cleared */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">On scene</p>
{unitsActive.length > 0 ? (
<div className="flex flex-wrap gap-1">
{unitsActive.map((u) => (
<span key={u} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{u}</span>
))}
</div>
) : (
<p className="text-xs text-ink-muted italic">None extracted.</p>
)}
</div>
<div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Cleared</p>
{unitsCleared.length > 0 ? (
<div className="flex flex-wrap gap-1">
{unitsCleared.map((u) => (
<span key={u} className="text-xs bg-transparent border border-line text-ink-muted px-2 py-0.5 rounded font-mono line-through">{u}</span>
))}
</div>
) : (
<p className="text-xs text-ink-muted italic">None yet.</p>
)}
</div>
</div>
{incident.vehicles?.length > 0 && (
<div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
<div className="flex flex-wrap gap-1">
{incident.vehicles.map((v) => (
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
))}
</div>
</div>
)}
</div>
{/* Right — the call spine */}
<div className="lg:col-span-2">
<p className="text-xs text-ink-muted uppercase tracking-wide mb-1">
Calls ({calls.length})
</p>
{/* Gate A / A2 — the spine renders transcripts. */}
{calls.length > 0 && <MachineOutputNotice variant="inline" className="mb-2" />}
{callsLoading ? (
<p className="text-ink-muted text-sm">Loading…</p>
) : calls.length === 0 ? (
<p className="text-ink-muted text-sm">No calls linked yet.</p>
) : (
<div>
{visible.map((c) => (
<CallSpineEntry
key={c.call_id}
call={c}
stopNumber={stopNumberByCallId.get(c.call_id)}
isAdmin={isAdmin}
/>
))}
{remaining > 0 && (
<button
onClick={() => setEarlierShown((n) => n + EARLIER_PAGE_SIZE)}
className="text-xs text-accent hover:underline mt-2"
>
{remaining} earlier call{remaining !== 1 ? "s" : ""}
</button>
)}
</div>
)}
</div>
</div>
</div>
);
}