Files
server-26/drb-frontend/lib/useCalls.ts
Logan Cusano 8fdedee25b
Build & Deploy / Build & push images (push) Successful in 4m18s
Build & Deploy / Deploy to VM (push) Failing after 2m7s
Frontend redesign chunk 3: type layer honesty and the duplicate fix
Declare the fields the backend already writes and the UI was discarding:
CallRecord gains location_coords, units, vehicles, cleared_units,
duplicate_of, srcaddr (intelligence.py ~315-327); IncidentRecord gains
units_active, units_cleared, location_mentions, last_thin_at
(incident_correlator.py _attach, ~1270-1300).

Filter duplicate_of client-side in useCalls.ts's three hooks (useCalls,
useCallsByIncident, useActiveCalls) so a call flagged as a second node's
recording of the same transmission no longer renders twice. Client-side
rather than a where() clause to avoid a new composite index.

Removes the two now-resolved DEFERRED.md entries (dedup/useCalls,
lib/types.ts field gaps).

Per UI_REDESIGN.md chunk 3.
2026-08-19 22:57:21 -04:00

162 lines
5.7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { collection, 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 { CallRecord } from "@/lib/types";
export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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) {
setCalls([]);
setLoading(false);
return;
}
// No org_id claim yet (still resolving, or genuinely unprovisioned —
// see ChromeSwitcher's no-claim guard) — an unfiltered query here
// would be exactly the cross-tenant read this scoping exists to
// close, so wait rather than fall back to "query everything".
if (!orgId) {
setCalls([]);
setLoading(false);
return;
}
const from = dateFromMs != null ? new Date(dateFromMs) : undefined;
const to = dateToMs != null ? new Date(dateToMs) : undefined;
const constraints = [
where("org_id", "==", orgId),
...(from ? [where("started_at", ">=", from)] : []),
...(to ? [where("started_at", "<=", to)] : []),
orderBy("started_at", "desc"),
limit(limitCount),
];
const q = query(collection(db, "calls"), ...constraints);
const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
unsubFirestore = onSnapshot(q, (snap) => {
const docs = snap.docs
.map((d) => {
const data = d.data();
return { ...data, started_at: toISO(data.started_at) ?? "", ended_at: toISO(data.ended_at) } as CallRecord;
})
// dedup.py flags a second node's recording of the same transmission
// with duplicate_of set to the canonical call_id — filtered client-
// side (not a where() clause) so this doesn't need a new composite
// index alongside the existing org_id/started_at query.
.filter((c) => !c.duplicate_of);
setCalls(docs);
setLoading(false);
}, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); });
});
return () => {
unsubAuth();
if (unsubFirestore) unsubFirestore();
};
}, [limitCount, dateFromMs, dateToMs, orgId]);
return { calls, loading, error };
}
export function useCallsByIncident(incidentId: string | null) {
const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true);
const { orgId } = useAuth();
useEffect(() => {
if (!incidentId) { setLoading(false); return; }
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setLoading(false); return; }
if (!orgId) { setCalls([]); setLoading(false); return; }
const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
const q = query(
collection(db, "calls"),
where("org_id", "==", orgId),
where("incident_ids", "array-contains", incidentId)
);
unsubFirestore = onSnapshot(q, (snap) => {
const docs = snap.docs
.map((d) => {
const data = d.data();
return { ...data, started_at: toISO(data.started_at) ?? "", ended_at: toISO(data.ended_at) } as CallRecord;
})
.filter((c) => !c.duplicate_of);
docs.sort((a, b) => a.started_at.localeCompare(b.started_at));
setCalls(docs);
setLoading(false);
}, (err: FirestoreError) => { console.error("useCallsByIncident:", err); setLoading(false); });
});
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, [incidentId, orgId]);
return { calls, loading };
}
export function useActiveCalls() {
const [calls, setCalls] = useState<CallRecord[]>([]);
const { orgId } = useAuth();
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) {
setCalls([]);
return;
}
if (!orgId) {
setCalls([]);
return;
}
const q = query(collection(db, "calls"), where("org_id", "==", orgId), where("status", "==", "active"));
const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
unsubFirestore = onSnapshot(q, (snap) => {
setCalls(
snap.docs
.map((d) => {
const data = d.data();
return { ...data, started_at: toISO(data.started_at) ?? "", ended_at: toISO(data.ended_at) } as CallRecord;
})
.filter((c) => !c.duplicate_of)
);
}, (err: FirestoreError) => { console.error("useActiveCalls:", err); });
});
return () => {
unsubAuth();
if (unsubFirestore) unsubFirestore();
};
}, [orgId]);
return calls;
}