Files
server-26/drb-frontend/lib/useIncidents.ts
T
Logan CusanoandClaude Opus 5.5 a267fdf982 Live wall: selected incident loads its own calls; resolved deep links; scrubber marks unloaded time (#239)
- LiveView: the selected incident merges useCallsByIncident (org_id +
  incident_ids CONTAINS, existing index, no orderBy/limit) into the calls fed
  to IncidentPanel and MapView, so a long incident keeps its full path and
  spine past the 500-call window. Stale rows from the previous selection are
  re-checked against the current id before merging.
- #inc= not in the active set is fetched with useIncident: a resolved one is
  selected, drawn on the map and shown under a "Resolved HH:MM" banner; a
  missing or other-org id shows "Incident not found". Auto-tour stays on
  active incidents.
- TimeScrubber: new loadedSince prop (oldest call when the window came back
  full). The axis before it is hatched and labelled "not loaded" instead of
  reading as a quiet stretch.
- useCalls returns hasMore (full page, counted before the duplicate_of
  filter); useIncident resets loading on a new id so a lookup doesn't flash
  "not found".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:25:13 -04:00

166 lines
5.5 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { IncidentRecord } from "@/lib/types";
const toISO = (v: unknown): string =>
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
(typeof v === "string" ? v : new Date().toISOString());
export function useIncidents(limitCount = 100, dateFrom?: Date, dateTo?: Date) {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// A full page means there may be older incidents past the limit; a short
// page means the query reached the end of the collection.
const [hasMore, setHasMore] = useState(false);
const { orgId } = useAuth();
// Stable ms values so the effect dependency doesn't fire on every render
const dateFromMs = dateFrom?.getTime();
const dateToMs = dateTo?.getTime();
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) {
setIncidents([]);
setLoading(false);
return;
}
if (!orgId) {
setIncidents([]);
setLoading(false);
return;
}
// A range on the ordered field rides the existing org_id/started_at index.
// Incident started_at is stored as a Python isoformat() STRING
// ("2026-09-20T12:00:00.123456+00:00", incident_correlator.py), not a
// Firestore timestamp — unlike calls. A Date bound compares by type and
// matches nothing, so the bounds go in as UTC ISO strings in the same
// shape, which then compare lexicographically in time order.
const isoBound = (ms: number) => new Date(ms).toISOString().replace("Z", "+00:00");
const q = query(
collection(db, "incidents"),
where("org_id", "==", orgId),
...(dateFromMs != null ? [where("started_at", ">=", isoBound(dateFromMs))] : []),
...(dateToMs != null ? [where("started_at", "<=", isoBound(dateToMs))] : []),
orderBy("started_at", "desc"),
limit(limitCount)
);
unsubFirestore = onSnapshot(q, (snap) => {
setIncidents(snap.docs.map((d) => {
const data = d.data();
return {
...data,
started_at: toISO(data.started_at),
updated_at: toISO(data.updated_at),
} as IncidentRecord;
}));
setHasMore(snap.size >= limitCount);
setLoading(false);
}, (err: FirestoreError) => {
console.error("useIncidents:", err);
setError(err.message);
setLoading(false);
});
});
return () => {
unsubAuth();
if (unsubFirestore) unsubFirestore();
};
}, [limitCount, dateFromMs, dateToMs, orgId]);
return { incidents, loading, error, hasMore };
}
export function useIncident(incidentId: string | null) {
const [incident, setIncident] = useState<IncidentRecord | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!incidentId) { setLoading(false); return; }
// A new id is a new lookup: without this, `loading` stays false from the
// previous id and a caller reads "not found" until the snapshot lands.
setLoading(true);
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setLoading(false); return; }
const ref = doc(db, "incidents", incidentId);
unsubFirestore = onSnapshot(ref, (snap) => {
if (snap.exists()) {
const data = snap.data();
setIncident({
...data,
started_at: toISO(data.started_at),
updated_at: toISO(data.updated_at),
} as IncidentRecord);
} else {
setIncident(null);
}
setLoading(false);
}, (err: FirestoreError) => {
console.error("useIncident:", err);
setLoading(false);
});
});
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, [incidentId]);
return { incident, loading };
}
export function useActiveIncidents() {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const { orgId } = useAuth();
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) {
setIncidents([]);
return;
}
if (!orgId) {
setIncidents([]);
return;
}
const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active"));
unsubFirestore = onSnapshot(q, (snap) => {
setIncidents(snap.docs.map((d) => {
const data = d.data();
return {
...data,
started_at: toISO(data.started_at),
updated_at: toISO(data.updated_at),
} as IncidentRecord;
}));
}, (err: FirestoreError) => { console.error("useActiveIncidents:", err); });
});
return () => {
unsubAuth();
if (unsubFirestore) unsubFirestore();
};
}, [orgId]);
return incidents;
}