Author SHA1 Message Date
logan 0712e7a437 correlator: LLM tier reads the scene transcript, not the whole call (#112)
Build & Deploy / Build & push images (push) Successful in 4m1s
Build & Deploy / Deploy to VM (push) Successful in 2m2s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 04:40:34 -04:00
logan a739fa64f0 frontend: safe fixes from the #109 punch-list (#113)
Build & Deploy / Build & push images (push) Successful in 4m5s
Build & Deploy / Deploy to VM (push) Successful in 2m33s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 00:13:35 -04:00
Logan CusanoandClaude Sonnet 5 d67b2057e6 frontend: safe fixes from the #109 punch-list
- CallSpineEntry.tsx: drop the dead `hasAudio` prop + the early `return null`
  that sat between hooks in InlinePlayer (React #310 risk). Parent already
  gates the mount on audio presence.
- NodeCard.tsx + nodes/page.tsx: pending-node card no longer double-fires.
  NodeCard gains `linkToDetail` (default true); the pending branch passes
  false so the wrapping onClick (open config modal) isn't swallowed by the
  inner <Link> navigation. List view unchanged.
- trips/page.tsx: TripCard badge now buckets on end_date >= today, matching
  the list's own upcoming/past split — an in-progress trip no longer shows a
  "Past" badge under "Upcoming".
- trips/page.tsx, NodeConfigModal.tsx, nodes/[id]/page.tsx: tall modals get
  `p-4` on the overlay + `max-h-[90vh] overflow-y-auto` on the panel so they
  don't clip on short viewports (incidents' CreateModal pattern).
- lib/types.ts: IncidentRecord.units / vehicles are optional now, matching
  Firestore (older docs omit them); incidents/[id] gains a `?? []` guard.

Untypechecked (no node/npm locally). next build in deploy.yml gates it.
Full list of remaining items in server-26 #109.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:07:54 -04:00
logan c1c3e89e1d frontend: fix map stacking + honest infra error states (#108)
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m3s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-06 23:49:19 -04:00
Logan CusanoandClaude Sonnet 5 968134f8ee frontend: fix map stacking + honest infra error states
From a live review of drb.cusano.net.

MapView.tsx / globals.css:
- The Leaflet map painted above the sticky Nav (z-40) and modal overlays, so
  on Live the account dropdown opened *behind* the map. Pin .leaflet-container
  to its own stacking context (position:relative; z-index:0) — keeps Leaflet's
  internal pane order, drops the whole map below app chrome. The map's own
  overlay UI (legend, rail, clock, fit-all) is outside .leaflet-container and
  unaffected. Chosen over raising Nav's z-index, which would float the sticky
  header over modal backdrops on ~7 pages.
- Basemap: the "Dark" tile URL is already CARTO's keyless dark raster (so a
  prod "API KEY REQUIRED" watermark is a stale build or CARTO rate-limiting
  the origin, not this code). Add NEXT_PUBLIC_MAP_TILE_URL as a build-time
  override so a keyed style drops in without a code change; add the OSM
  attribution the keyless CARTO tiles require.

incidents/page.tsx, alerts/page.tsx:
- Both dumped raw Firestore "requires an index / PERMISSION_DENIED" strings
  (with a console.firebase URL) straight into the UI when the composite
  indexes aren't deployed (server-26 #13/#51). Collapse those known infra
  failures to a plain sentence; any other error passes through verbatim so a
  real bug still shows. alerts also now surfaces the events-query error at
  all — it was swallowed, showing a false "No alerts triggered yet." on a
  public-safety screen.

onboarding/page.tsx: stale comment (/dashboard -> "/").

Untypechecked (no node/npm locally); presentational only — one string
helper, one added error branch, a CSS rule, two tile-URL constants, a
comment. next build in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:43:22 -04:00
13 changed files with 75 additions and 25 deletions
+7 -1
View File
@@ -186,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
export default function AlertsPage() { export default function AlertsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { alerts, loading } = useAlerts(); const { alerts, loading, error } = useAlerts();
const [tab, setTab] = useState<"events" | "rules">("events"); const [tab, setTab] = useState<"events" | "rules">("events");
async function handleAcknowledge(id: string) { async function handleAcknowledge(id: string) {
@@ -226,6 +226,12 @@ export default function AlertsPage() {
{tab === "events" && ( {tab === "events" && (
loading ? ( loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-gray-500 text-sm font-mono">Loading…</p>
) : error ? (
<p className="text-red-400 text-sm font-mono">
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
: `Couldn't load alerts: ${error}`}
</p>
) : alerts.length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p> <p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
) : ( ) : (
+14
View File
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
animation: pulse-ring 1.8s ease-out infinite; animation: pulse-ring 1.8s ease-out infinite;
} }
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
* map container to its own low stacking context keeps Leaflet's internal layer
* order intact while dropping the whole map (tiles + controls) below the app
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
* outside .leaflet-container, so it is unaffected and still renders on top.
*/
.leaflet-container {
position: relative;
z-index: 0;
}
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
+3 -2
View File
@@ -100,6 +100,7 @@ export default function IncidentDetailPage() {
const displayTags = incident.tags.filter((t) => t !== "auto-generated"); const displayTags = incident.tags.filter((t) => t !== "auto-generated");
const unitsActive = incident.units_active ?? incident.units ?? []; const unitsActive = incident.units_active ?? incident.units ?? [];
const unitsCleared = incident.units_cleared ?? []; const unitsCleared = incident.units_cleared ?? [];
const vehicles = incident.vehicles ?? [];
const active = incident.status === "active"; const active = incident.status === "active";
const visible = newestFirst.slice(0, earlierShown); const visible = newestFirst.slice(0, earlierShown);
@@ -213,11 +214,11 @@ export default function IncidentDetailPage() {
</div> </div>
</div> </div>
{incident.vehicles?.length > 0 && ( {vehicles.length > 0 && (
<div> <div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p> <p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{incident.vehicles.map((v) => ( {vehicles.map((v) => (
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span> <span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
))} ))}
</div> </div>
+12 -1
View File
@@ -28,6 +28,17 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
type SortMode = "recent" | "severity"; type SortMode = "recent" | "severity";
// The Firestore client surfaces a missing composite index or an undeployed
// ruleset as a raw multi-line string with a console URL in it — not something
// to put in front of an operator. Collapse the known infra failures to a plain
// line; pass anything else straight through so a real bug still shows.
function friendlyIncidentsError(raw: string): string {
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
}
return `Couldn't load incidents: ${raw}`;
}
function fmtTime(iso: string) { function fmtTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; } try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
} }
@@ -286,7 +297,7 @@ export default function IncidentsPage() {
recorded yet" over the top of it told the operator the radio was 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. */} quiet when the page had simply failed to load — server-26#13. */}
{filtered.length === 0 && error && ( {filtered.length === 0 && error && (
<ErrorBanner message={`Couldn't load incidents: ${error}`} /> <ErrorBanner message={friendlyIncidentsError(error)} />
)} )}
{filtered.length === 0 && !error && ( {filtered.length === 0 && !error && (
+1 -1
View File
@@ -60,7 +60,7 @@ function DiscordJoinModal({
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm" className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm max-h-[90vh] overflow-y-auto"
> >
<h3 className="text-white font-semibold">Join Discord Voice</h3> <h3 className="text-white font-semibold">Join Discord Voice</h3>
<div> <div>
+1 -1
View File
@@ -42,7 +42,7 @@ export default function NodesPage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pending.map((n) => ( {pending.map((n) => (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer"> <div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} /> <NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
// Firebase custom claims only show up in a *freshly fetched* ID token — // Firebase custom claims only show up in a *freshly fetched* ID token —
// getIdTokenResult(true) inside refreshClaims forces that fetch, then // getIdTokenResult(true) inside refreshClaims forces that fetch, then
// AuthProvider's own state (orgId) updates and the effect above // AuthProvider's own state (orgId) updates and the effect above
// redirects to /dashboard. // redirects to "/" (Live).
await refreshClaims(); await refreshClaims();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again."); setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
+5 -3
View File
@@ -22,7 +22,9 @@ function TripCard({ trip, isAdmin, onDelete }: {
}) { }) {
const router = useRouter(); const router = useRouter();
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const upcoming = trip.start_date >= today; // Bucket and badge must agree: the list groups on end_date (page.tsx ~L176),
// so a trip isn't "Past" until it's over, not when it starts.
const upcoming = trip.end_date >= today;
const attendeeCount = Object.keys(trip.attendees ?? {}).length; const attendeeCount = Object.keys(trip.attendees ?? {}).length;
return ( return (
@@ -97,10 +99,10 @@ function CreateModal({ onClose, onCreate }: {
} }
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4" className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
> >
<h2 className="text-white font-bold">New Trip</h2> <h2 className="text-white font-bold">New Trip</h2>
+2 -4
View File
@@ -23,7 +23,7 @@ function fmtClock(s: number): string {
return `${m}:${r.toString().padStart(2, "0")}`; return `${m}:${r.toString().padStart(2, "0")}`;
} }
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) { function InlinePlayer({ callId }: { callId: string }) {
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -32,8 +32,6 @@ function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
if (!hasAudio) return null;
async function ensureUrl() { async function ensureUrl() {
if (url || loading) return; if (url || loading) return;
setLoading(true); setLoading(true);
@@ -169,7 +167,7 @@ export function CallSpineEntry({
{hasAudio && ( {hasAudio && (
<div className="mt-1.5"> <div className="mt-1.5">
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} /> <InlinePlayer callId={call.call_id} />
</div> </div>
)} )}
+14 -3
View File
@@ -24,6 +24,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
}); });
// ── Basemap tiles ─────────────────────────────────────────────────────────────
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
const MAP_TILE_URL =
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const MAP_TILE_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/">CARTO</a>';
// ── Colour ──────────────────────────────────────────────────────────────────── // ── Colour ────────────────────────────────────────────────────────────────────
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident // Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
// type is carried by the glyph knocked out of the pin, never by colour, and // type is carried by the glyph knocked out of the pin, never by colour, and
@@ -540,14 +551,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Base layers */} {/* Base layers */}
<LayersControl.BaseLayer checked name="Dark"> <LayersControl.BaseLayer checked name="Dark">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" url={MAP_TILE_URL}
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light"> <LayersControl.BaseLayer name="Light">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets"> <LayersControl.BaseLayer name="Streets">
+10 -4
View File
@@ -5,15 +5,20 @@ import type { NodeRecord, SystemRecord } from "@/lib/types";
interface Props { interface Props {
node: NodeRecord; node: NodeRecord;
system?: SystemRecord; system?: SystemRecord;
/**
* When false, the card renders without its `/nodes/[id]` Link wrapper so a
* parent click handler can take the interaction (pending nodes open the
* config modal instead of navigating). Defaults to true.
*/
linkToDetail?: boolean;
} }
export function NodeCard({ node, system }: Props) { export function NodeCard({ node, system, linkToDetail = true }: Props) {
const lastSeen = node.last_seen const lastSeen = node.last_seen
? new Date(node.last_seen).toLocaleTimeString() ? new Date(node.last_seen).toLocaleTimeString()
: "never"; : "never";
return ( const body = (
<Link href={`/nodes/${node.node_id}`}>
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer"> <div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div> <div>
@@ -58,6 +63,7 @@ export function NodeCard({ node, system }: Props) {
</div> </div>
)} )}
</div> </div>
</Link>
); );
return linkToDetail ? <Link href={`/nodes/${node.node_id}`}>{body}</Link> : body;
} }
+2 -2
View File
@@ -50,8 +50,8 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
const selectedPreset = PRESETS.find((p) => p.value === preset); const selectedPreset = PRESETS.find((p) => p.value === preset);
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono"> <div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-semibold mb-1">Configure Node</h2> <h2 className="text-white font-semibold mb-1">Configure Node</h2>
<p className="text-gray-400 text-sm mb-5"> <p className="text-gray-400 text-sm mb-5">
<span className="text-indigo-400">{node.node_id}</span> connected for the first time. <span className="text-indigo-400">{node.node_id}</span> connected for the first time.
+3 -2
View File
@@ -143,8 +143,9 @@ export interface IncidentRecord {
call_ids: string[]; call_ids: string[];
system_ids: string[]; system_ids: string[];
talkgroup_ids: string[]; talkgroup_ids: string[];
units: string[]; /** Omitted on incident docs written before these fields existed. */
vehicles: string[]; units?: string[];
vehicles?: string[];
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */ /** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
units_active?: string[]; units_active?: string[];
/** Units that reported clearing/back in service on this incident. */ /** Units that reported clearing/back in service on this incident. */