- lib/attention.ts: pure snapshot diff (Major opened, severity escalated, pursuit path gained a stop, priority keyword in a new transcript) with a silent baseline on page open; calls baseline separately on their first non-empty snapshot. Diffed over all active incidents so widening a filter is never "new"; emitted, laned and pulsed only for the FILTERED view. - components/live/NewLane.tsx: "NEW · last 15 min" overlay above the rail (desktop) / compact chip (mobile), Gate A notice included. - components/live/AttentionControls.tsx: opt-in Web Audio tone (enabling click unlocks audio) and Web Notifications while the tab is hidden, body carries the machine-output notice; prefs + keywords in localStorage. - LiveView wires it and passes pulseIncidentIds to MapView (2 min pulse). - useIncidents: useActiveIncidentsState exposes a loaded flag (first server snapshot) so the baseline never sees an empty pre-load list. - MachineOutputNotice exports MACHINE_OUTPUT_NOTICE_TEXT (same wording). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
6.5 KiB
TypeScript
185 lines
6.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);
|
|
// The id the current `incident`/`loading` answer belongs to. On the render
|
|
// where the id changes the effect hasn't run yet, so `loading` alone would
|
|
// still read false from the previous id and a caller would flash "not found".
|
|
const [settledId, setSettledId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!incidentId) { setLoading(false); return; }
|
|
setLoading(true);
|
|
let unsubFirestore: (() => void) | undefined;
|
|
|
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
|
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
|
if (!user) { setLoading(false); setSettledId(incidentId); 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);
|
|
setSettledId(incidentId);
|
|
}, (err: FirestoreError) => {
|
|
console.error("useIncident:", err);
|
|
setIncident(null); // a denied/failed read must not leave the previous id's doc
|
|
setLoading(false);
|
|
setSettledId(incidentId);
|
|
});
|
|
});
|
|
|
|
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
|
|
}, [incidentId]);
|
|
|
|
return { incident, loading: loading || (!!incidentId && settledId !== incidentId) };
|
|
}
|
|
|
|
/**
|
|
* Active incidents plus whether the first SERVER snapshot has landed
|
|
* (server-26#238). `loaded` is what lets the wall tell "nothing active" from
|
|
* "not loaded yet" — attention cues baseline on it, so opening the page never
|
|
* fires a cue for every incident already open. Reset on sign-out / org change.
|
|
*/
|
|
export function useActiveIncidentsState(): { incidents: IncidentRecord[]; loaded: boolean } {
|
|
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const { orgId } = useAuth();
|
|
|
|
useEffect(() => {
|
|
let unsubFirestore: (() => void) | undefined;
|
|
setLoaded(false);
|
|
|
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
|
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
|
setLoaded(false);
|
|
|
|
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;
|
|
}));
|
|
if (!snap.metadata.fromCache) setLoaded(true);
|
|
}, (err: FirestoreError) => { console.error("useActiveIncidents:", err); });
|
|
});
|
|
|
|
return () => {
|
|
unsubAuth();
|
|
if (unsubFirestore) unsubFirestore();
|
|
};
|
|
}, [orgId]);
|
|
|
|
return { incidents, loaded };
|
|
}
|
|
|
|
export function useActiveIncidents() {
|
|
return useActiveIncidentsState().incidents;
|
|
}
|