Files
server-26/drb-frontend/app/incidents/page.tsx
T
Logan CusanoandClaude Opus 5 be79499635
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
Give the nav's dead links somewhere to land
Three of the app's routes were referenced but never existed, so the redesign's
navigation pointed at 404s from several directions.

/dashboard was the post-login and fallback redirect target in nine places --
login, onboarding, middleware, the admin/nodes/systems/tokens/settings guards,
and the marketing header -- but app/dashboard/ was never created. Signing in
normally dropped the user on a 404. The real signed-in home is "/", which
app/page.tsx already renders as LiveView for an authed user with an org, and
which the nav labels "Live"; all nine now point there.

Nav also linked /watch and /network, neither of which existed. /watch is the
alerts screen under its redesign name, so it re-exports app/alerts/page.tsx
and /alerts stays reachable for old links. /network is new: the "my equipment"
hub the redesign moved /nodes, /systems and /tokens behind and then never
built, which had left /systems and /tokens with no entry point in the UI at
all. Its hooks all run before the admin/operator guard, per d041c86.

Separately, the admin page's guard read isAdmin without authLoading, so every
cold load of /admin -- typed URL, hard refresh, bookmark -- redirected away
while the Firebase claims were still resolving. Admin was only reachable by
clicking through from an already-mounted page. Now it waits, like every other
guarded route does.

And /incidents no longer lies about an empty list: a failed Firestore query
leaves `incidents` empty just as a quiet night does, and the page was printing
"No incidents recorded yet" over the top of a missing-composite-index error.
useIncidents already returned `error`; the page just ignored it. It now renders
an ErrorBanner instead, so the undeployed indexes in server-26#13 read as a
failure rather than as silence on the radio.

Closes server-26#30, server-26#31. server-26#13 stays open -- the rules and
indexes still have to be pushed to the live project by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:28:02 -04:00

311 lines
12 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 { 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>}
/>
<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>
);
}