- 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>
167 lines
6.0 KiB
TypeScript
167 lines
6.0 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);
|
|
// True when the query returned a full page (snap.size, counted BEFORE the
|
|
// duplicate_of filter below) — there may be older calls past the limit.
|
|
// calls.length can't answer that: dedup drops rows from a full page.
|
|
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) {
|
|
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);
|
|
setHasMore(snap.size >= limitCount);
|
|
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, hasMore };
|
|
}
|
|
|
|
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;
|
|
}
|