Live wall attention cues: NEW lane, pin pulse, opt-in tone and notifications (#238)
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1d6e79ba25
commit
eb0faf2aea
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Attention cues for the live wall (server-26#238): what changed between two
|
||||
* snapshots that a person should look at, the "NEW · last 15 min" lane, and
|
||||
* which pins pulse.
|
||||
*
|
||||
* `diffAttention` is pure: (previous state, snapshot) → (next state, events).
|
||||
* The first snapshot is a BASELINE and emits nothing, so opening the page
|
||||
* never fires a cue for every incident already open. Calls baseline
|
||||
* separately, on the first non-empty call snapshot, because the incident and
|
||||
* call subscriptions land independently.
|
||||
*
|
||||
* The diff runs over ALL active incidents, not the filtered list: diffing the
|
||||
* filtered list would make widening a filter look like a burst of "new"
|
||||
* Majors. Visibility is applied when an event is emitted (`isVisibleEvent`),
|
||||
* against the filter in force at that moment — so a cue or notification
|
||||
* never fires for an incident filtered out of view.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CallRecord, IncidentRecord } from "@/lib/types";
|
||||
import { SEVERITY_LABEL, isKnownSeverity, severityRank } from "@/lib/severity";
|
||||
import { groupCallsByIncident, pathStops } from "@/lib/liveWall";
|
||||
|
||||
/** How long something counts as "new" on the lane. */
|
||||
export const LANE_WINDOW_MS = 15 * 60_000;
|
||||
/** How long a new / changed incident's pin pulses. */
|
||||
export const PULSE_MS = 2 * 60_000;
|
||||
export const DEFAULT_KEYWORDS: readonly string[] = ["MAYDAY", "10-13", "shots fired", "officer down"];
|
||||
const MAJOR = 3;
|
||||
/** A stop on an incident with no known stops counts as new only if it started after (first seen − this). */
|
||||
const STOP_GRACE_MS = 2 * 60_000;
|
||||
/** Forget incidents gone from the active list this long (state stays bounded on an all-day wall). */
|
||||
const FORGET_MS = 6 * 3600_000;
|
||||
/** Checked-call memory: anything older than the lane window is never cued anyway. */
|
||||
const CALL_MEMORY_MS = LANE_WINDOW_MS + 30 * 60_000;
|
||||
const MAX_LANE = 12;
|
||||
const MAX_LOG = 200;
|
||||
|
||||
export type AttentionKind = "major" | "escalated" | "stop" | "keyword";
|
||||
|
||||
export interface AttentionEvent {
|
||||
/** Stable key (also the notification tag's suffix). */
|
||||
id: string;
|
||||
kind: AttentionKind;
|
||||
/** Incidents this concerns; empty = a call not linked to any incident yet. */
|
||||
incidentIds: string[];
|
||||
callId: string | null;
|
||||
at: number;
|
||||
/** Short reason chip, e.g. "Major", "↑ Moderate", "+1 stop", "MAYDAY". */
|
||||
label: string;
|
||||
/** Incident title, or the talkgroup for an unlinked call. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface IncState {
|
||||
rank: number;
|
||||
/** When this incident first counted as new: its start for the baseline, else when the wall first saw it. */
|
||||
seenAt: number;
|
||||
/** When the wall started tracking it (stop-grace floor). */
|
||||
knownSince: number;
|
||||
lastSeen: number;
|
||||
stopIds: Set<string>;
|
||||
newestStopAt: number | null;
|
||||
}
|
||||
|
||||
export interface AttentionState {
|
||||
baselineAt: number;
|
||||
callsBaselined: boolean;
|
||||
incidents: Map<string, IncState>;
|
||||
/** call_id → started_at ms, for calls whose transcript has been keyword-checked. */
|
||||
checkedCalls: Map<string, number>;
|
||||
}
|
||||
|
||||
const ms = (iso: string | null | undefined): number => {
|
||||
const t = iso ? Date.parse(iso) : NaN;
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
};
|
||||
|
||||
function callIncidentIds(c: CallRecord): string[] {
|
||||
return c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
|
||||
}
|
||||
|
||||
function transcriptOf(c: CallRecord): string {
|
||||
return c.transcript_corrected || c.transcript || "";
|
||||
}
|
||||
|
||||
/** Non-ghost geocoded stops of a movement incident (pursuit path), oldest first. Empty otherwise. */
|
||||
function movementStops(inc: IncidentRecord, calls: CallRecord[] | undefined): { id: string; at: number }[] {
|
||||
const path = pathStops(inc, calls);
|
||||
if (!path.movement) return [];
|
||||
return path.stops.filter((s) => !s.ghost).map((s) => ({ id: s.call.call_id, at: ms(s.call.started_at) }));
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured keywords found in `text`, case-insensitive, whole-word: "10-13"
|
||||
* does not fire on "210-135". Spaces match any whitespace and a hyphen may be
|
||||
* a space or absent ("10 13", "1013"), since transcription renders codes both ways.
|
||||
*/
|
||||
export function matchKeywords(text: string, keywords: readonly string[]): string[] {
|
||||
if (!text) return [];
|
||||
const t = text.toLowerCase();
|
||||
const hits: string[] = [];
|
||||
for (const k of keywords) {
|
||||
const kw = k.trim().toLowerCase();
|
||||
if (!kw) continue;
|
||||
const body = kw.split(/\s+/).map((w) => escapeRe(w).replace(/-/g, "[-\\s]?")).join("\\s+");
|
||||
if (new RegExp(`(^|[^a-z0-9])${body}($|[^a-z0-9])`).test(t)) hits.push(k.trim());
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function sevLabel(sev: string | null | undefined): string {
|
||||
return isKnownSeverity(sev) ? SEVERITY_LABEL[sev] : "Unrated";
|
||||
}
|
||||
|
||||
/**
|
||||
* One snapshot step. `prev === null` → baseline (no events). Never mutates `prev`.
|
||||
* `incidents` = every active incident (unfiltered); `calls` = every call the
|
||||
* wall has loaded (recent window + the selected incident's own calls).
|
||||
*/
|
||||
export function diffAttention(
|
||||
prev: AttentionState | null,
|
||||
incidents: IncidentRecord[],
|
||||
calls: CallRecord[],
|
||||
keywords: readonly string[],
|
||||
now: number = Date.now(),
|
||||
): { state: AttentionState; events: AttentionEvent[] } {
|
||||
const byInc = groupCallsByIncident(calls);
|
||||
const events: AttentionEvent[] = [];
|
||||
|
||||
if (!prev) {
|
||||
const map = new Map<string, IncState>();
|
||||
for (const inc of incidents) {
|
||||
const stops = movementStops(inc, byInc.get(inc.incident_id));
|
||||
map.set(inc.incident_id, {
|
||||
rank: severityRank(inc.severity),
|
||||
seenAt: ms(inc.started_at) || now,
|
||||
knownSince: now,
|
||||
lastSeen: now,
|
||||
stopIds: new Set(stops.map((s) => s.id)),
|
||||
newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null,
|
||||
});
|
||||
}
|
||||
const checked = new Map<string, number>();
|
||||
for (const c of calls) if (transcriptOf(c)) checked.set(c.call_id, ms(c.started_at));
|
||||
return { state: { baselineAt: now, callsBaselined: calls.length > 0, incidents: map, checkedCalls: checked }, events };
|
||||
}
|
||||
|
||||
// The first non-empty call snapshot is the calls' own baseline: record what
|
||||
// is there, and re-seed every known incident's stops from it, silently.
|
||||
const baselineCalls = !prev.callsBaselined && calls.length > 0;
|
||||
const map = new Map<string, IncState>();
|
||||
const present = new Set<string>();
|
||||
|
||||
for (const inc of incidents) {
|
||||
const id = inc.incident_id;
|
||||
present.add(id);
|
||||
const rank = severityRank(inc.severity);
|
||||
const stops = movementStops(inc, byInc.get(id));
|
||||
const p = prev.incidents.get(id);
|
||||
const title = inc.title || "Untitled incident";
|
||||
|
||||
if (!p) {
|
||||
map.set(id, {
|
||||
rank, seenAt: now, knownSince: now, lastSeen: now,
|
||||
stopIds: new Set(stops.map((s) => s.id)),
|
||||
newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null,
|
||||
});
|
||||
if (rank === MAJOR) events.push({ id: `major:${id}`, kind: "major", incidentIds: [id], callId: null, at: now, label: "Major", title });
|
||||
continue;
|
||||
}
|
||||
|
||||
const st: IncState = { ...p, rank, lastSeen: now, stopIds: new Set(p.stopIds) };
|
||||
// An unrated incident getting its first rating is classification, not
|
||||
// escalation — unless that first rating is Major.
|
||||
if (rank > p.rank && (p.rank >= 0 || rank === MAJOR)) {
|
||||
events.push({
|
||||
id: `sev:${id}:${rank}`,
|
||||
kind: rank === MAJOR && p.rank < 0 ? "major" : "escalated",
|
||||
incidentIds: [id], callId: null, at: now,
|
||||
label: p.rank < 0 ? sevLabel(inc.severity) : `↑ ${sevLabel(inc.severity)}`,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
if (stops.length) {
|
||||
const floor = st.newestStopAt ?? st.knownSince - STOP_GRACE_MS;
|
||||
const fresh = baselineCalls ? [] : stops.filter((s) => !p.stopIds.has(s.id) && s.at > floor);
|
||||
for (const s of stops) st.stopIds.add(s.id);
|
||||
st.newestStopAt = Math.max(st.newestStopAt ?? 0, ...stops.map((s) => s.at));
|
||||
if (fresh.length) {
|
||||
const newest = fresh[fresh.length - 1];
|
||||
events.push({
|
||||
id: `stop:${id}:${newest.id}`, kind: "stop", incidentIds: [id], callId: newest.id, at: now,
|
||||
label: fresh.length === 1 ? "+1 stop" : `+${fresh.length} stops`, title,
|
||||
});
|
||||
}
|
||||
}
|
||||
map.set(id, st);
|
||||
}
|
||||
// Keep incidents that dropped out for a while, so one that flickers out of
|
||||
// the active list and back is not "new" again.
|
||||
prev.incidents.forEach((st, id) => {
|
||||
if (!present.has(id) && now - st.lastSeen < FORGET_MS) map.set(id, st);
|
||||
});
|
||||
|
||||
const checked = new Map<string, number>();
|
||||
prev.checkedCalls.forEach((t, id) => { if (now - t < CALL_MEMORY_MS) checked.set(id, t); });
|
||||
const incTitle = new Map(incidents.map((i) => [i.incident_id, i.title || "Untitled incident"]));
|
||||
for (const c of calls) {
|
||||
const text = transcriptOf(c);
|
||||
if (!text || checked.has(c.call_id)) continue;
|
||||
const started = ms(c.started_at);
|
||||
checked.set(c.call_id, started || now);
|
||||
if (baselineCalls || !prev.callsBaselined) continue;
|
||||
if (now - started > LANE_WINDOW_MS) continue; // an old call pulled in late (e.g. by selection) is not news
|
||||
const hits = matchKeywords(text, keywords);
|
||||
if (!hits.length) continue;
|
||||
const ids = callIncidentIds(c);
|
||||
events.push({
|
||||
id: `kw:${c.call_id}`, kind: "keyword", incidentIds: ids, callId: c.call_id, at: now,
|
||||
label: hits.join(" · ").toUpperCase(),
|
||||
title: ids.map((i) => incTitle.get(i)).find(Boolean) ?? c.talkgroup_name ?? (c.talkgroup_id != null ? `TG ${c.talkgroup_id}` : "Unlinked call"),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
state: { baselineAt: prev.baselineAt, callsBaselined: prev.callsBaselined || baselineCalls, incidents: map, checkedCalls: checked },
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In view under the current filter: a linked event needs one of its incidents
|
||||
* in the filtered list (never the call alone — a filtered-out incident's call
|
||||
* must not cue); an unlinked call needs the call itself in the filtered calls.
|
||||
*/
|
||||
export function isVisibleEvent(e: AttentionEvent, visibleIds: ReadonlySet<string>, visibleCallIds: ReadonlySet<string>): boolean {
|
||||
if (e.incidentIds.length) return e.incidentIds.some((id) => visibleIds.has(id));
|
||||
return !!e.callId && visibleCallIds.has(e.callId);
|
||||
}
|
||||
|
||||
export interface LaneEntry {
|
||||
key: string;
|
||||
incidentId: string | null;
|
||||
title: string;
|
||||
severity: string | null;
|
||||
/** Most recent reason to be on the lane (ms). */
|
||||
at: number;
|
||||
/** "New" or the latest event's label. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** The lane: visible incidents new in the window or with a cue in it, plus unlinked keyword calls; newest first. */
|
||||
export function laneEntries(
|
||||
visible: IncidentRecord[],
|
||||
seenAt: ReadonlyMap<string, number>,
|
||||
log: AttentionEvent[],
|
||||
now: number,
|
||||
visibleCallIds: ReadonlySet<string>,
|
||||
): LaneEntry[] {
|
||||
const since = now - LANE_WINDOW_MS;
|
||||
const latest = new Map<string, AttentionEvent>();
|
||||
for (const e of log) {
|
||||
if (e.at < since) continue;
|
||||
for (const id of e.incidentIds) {
|
||||
const cur = latest.get(id);
|
||||
if (!cur || e.at >= cur.at) latest.set(id, e);
|
||||
}
|
||||
}
|
||||
const out: LaneEntry[] = [];
|
||||
for (const inc of visible) {
|
||||
const seen = seenAt.get(inc.incident_id) ?? 0;
|
||||
const ev = latest.get(inc.incident_id);
|
||||
const isNew = seen >= since;
|
||||
if (!isNew && !ev) continue;
|
||||
const at = Math.max(isNew ? seen : 0, ev?.at ?? 0);
|
||||
out.push({
|
||||
key: inc.incident_id, incidentId: inc.incident_id, title: inc.title || "Untitled incident", severity: inc.severity,
|
||||
at, reason: ev && ev.at >= (isNew ? seen : 0) ? ev.label : "New",
|
||||
});
|
||||
}
|
||||
for (const e of log) {
|
||||
if (e.at < since || e.incidentIds.length || !e.callId || !visibleCallIds.has(e.callId)) continue;
|
||||
out.push({ key: e.id, incidentId: null, title: e.title, severity: null, at: e.at, reason: e.label });
|
||||
}
|
||||
return out.sort((a, b) => b.at - a.at).slice(0, MAX_LANE);
|
||||
}
|
||||
|
||||
/** Visible incidents first seen, or cued, within PULSE_MS. */
|
||||
export function pulseIds(visibleIds: ReadonlySet<string>, seenAt: ReadonlyMap<string, number>, log: AttentionEvent[], now: number): Set<string> {
|
||||
const since = now - PULSE_MS;
|
||||
const out = new Set<string>();
|
||||
seenAt.forEach((t, id) => { if (t >= since && visibleIds.has(id)) out.add(id); });
|
||||
for (const e of log) if (e.at >= since) for (const id of e.incidentIds) if (visibleIds.has(id)) out.add(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Preferences (per browser; a convenience, so storage failure = defaults) ──
|
||||
|
||||
export interface AttentionPrefs {
|
||||
/** Audible cue. OFF until the viewer turns it on (autoplay policy + no surprise noise). */
|
||||
tone: boolean;
|
||||
/** Browser notifications while the tab is hidden. OFF until granted and turned on. */
|
||||
notify: boolean;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
const PREFS_KEY = "drb.wall.attention";
|
||||
export const DEFAULT_ATTENTION_PREFS: AttentionPrefs = { tone: false, notify: false, keywords: [...DEFAULT_KEYWORDS] };
|
||||
|
||||
export function loadAttentionPrefs(): AttentionPrefs {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PREFS_KEY);
|
||||
if (!raw) return DEFAULT_ATTENTION_PREFS;
|
||||
const v = JSON.parse(raw) as Partial<AttentionPrefs>;
|
||||
return {
|
||||
tone: v.tone === true,
|
||||
notify: v.notify === true,
|
||||
keywords: Array.isArray(v.keywords)
|
||||
? v.keywords.filter((k): k is string => typeof k === "string" && !!k.trim()).slice(0, 30)
|
||||
: [...DEFAULT_KEYWORDS],
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_ATTENTION_PREFS;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAttentionPrefs(p: AttentionPrefs): void {
|
||||
try { window.localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch { /* storage blocked */ }
|
||||
}
|
||||
|
||||
/** "MAYDAY, 10-13 , ,shots fired" → ["MAYDAY", "10-13", "shots fired"] (deduped, case-insensitively). */
|
||||
export function parseKeywords(s: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of s.split(",")) {
|
||||
const k = part.trim().replace(/\s+/g, " ");
|
||||
if (!k || seen.has(k.toLowerCase())) continue;
|
||||
seen.add(k.toLowerCase());
|
||||
out.push(k);
|
||||
}
|
||||
return out.slice(0, 30);
|
||||
}
|
||||
|
||||
// ── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface UseAttentionInput {
|
||||
/** True once the incident list's first server snapshot has landed (and the org is known). */
|
||||
ready: boolean;
|
||||
/** Changing this (e.g. the org) starts a fresh baseline. */
|
||||
resetKey: string | null;
|
||||
incidents: IncidentRecord[];
|
||||
calls: CallRecord[];
|
||||
keywords: readonly string[];
|
||||
/** The FILTERED incidents, their ids, and the filtered call ids — what is in view right now. */
|
||||
visible: IncidentRecord[];
|
||||
visibleIds: ReadonlySet<string>;
|
||||
visibleCallIds: ReadonlySet<string>;
|
||||
/** Called with each snapshot's new, in-view events (tone / notification). */
|
||||
onEvents?: (events: AttentionEvent[]) => void;
|
||||
}
|
||||
|
||||
export function useAttention({ ready, resetKey, incidents, calls, keywords, visible, visibleIds, visibleCallIds, onEvents }: UseAttentionInput) {
|
||||
const stateRef = useRef<AttentionState | null>(null);
|
||||
const keyRef = useRef<string | null>(resetKey);
|
||||
const [log, setLog] = useState<AttentionEvent[]>([]);
|
||||
const [seenAt, setSeenAt] = useState<Map<string, number>>(new Map());
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const visRef = useRef({ visibleIds, visibleCallIds });
|
||||
visRef.current = { visibleIds, visibleCallIds };
|
||||
const onEventsRef = useRef(onEvents);
|
||||
onEventsRef.current = onEvents;
|
||||
const kwKey = keywords.join("\u0001");
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyRef.current !== resetKey) {
|
||||
keyRef.current = resetKey;
|
||||
stateRef.current = null;
|
||||
setLog([]);
|
||||
setSeenAt(new Map());
|
||||
}
|
||||
if (!ready) return;
|
||||
const t = Date.now();
|
||||
const { state, events } = diffAttention(stateRef.current, incidents, calls, keywords, t);
|
||||
stateRef.current = state;
|
||||
const seen = new Map<string, number>();
|
||||
state.incidents.forEach((s, id) => seen.set(id, s.seenAt));
|
||||
setSeenAt(seen);
|
||||
setNow(t);
|
||||
if (!events.length) return;
|
||||
setLog((cur) => [...cur.filter((e) => t - e.at < LANE_WINDOW_MS), ...events].slice(-MAX_LOG));
|
||||
const { visibleIds: vi, visibleCallIds: vc } = visRef.current;
|
||||
const shown = events.filter((e) => isVisibleEvent(e, vi, vc));
|
||||
if (shown.length) onEventsRef.current?.(shown);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, resetKey, incidents, calls, kwKey]);
|
||||
|
||||
const lane = useMemo(() => laneEntries(visible, seenAt, log, now, visibleCallIds), [visible, seenAt, log, now, visibleCallIds]);
|
||||
const pulse = useMemo(() => pulseIds(visibleIds, seenAt, log, now), [visibleIds, seenAt, log, now]);
|
||||
return { lane, pulseIds: pulse, now };
|
||||
}
|
||||
@@ -128,15 +128,24 @@ export function useIncident(incidentId: string | null) {
|
||||
return { incident, loading: loading || (!!incidentId && settledId !== incidentId) };
|
||||
}
|
||||
|
||||
export function useActiveIncidents() {
|
||||
/**
|
||||
* 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([]);
|
||||
@@ -157,6 +166,7 @@ export function useActiveIncidents() {
|
||||
updated_at: toISO(data.updated_at),
|
||||
} as IncidentRecord;
|
||||
}));
|
||||
if (!snap.metadata.fromCache) setLoaded(true);
|
||||
}, (err: FirestoreError) => { console.error("useActiveIncidents:", err); });
|
||||
});
|
||||
|
||||
@@ -166,5 +176,9 @@ export function useActiveIncidents() {
|
||||
};
|
||||
}, [orgId]);
|
||||
|
||||
return incidents;
|
||||
return { incidents, loaded };
|
||||
}
|
||||
|
||||
export function useActiveIncidents() {
|
||||
return useActiveIncidentsState().incidents;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user