Files
server-26/drb-frontend/lib/useOutages.ts
T
Logan CusanoandClaude Opus 5.5 e7070a0afa Outages review fixes: reset on new target, memoize marker icons, coverage note in popup (#184)
- useOutagesIn kept the previous incident's answer while fetching the next
  one (IncidentPanel isn't keyed), so a newly selected incident briefly read
  'none reported here'. Drop the old answer when the target changes.
- OutageLayer built a fresh divIcon per marker every render; MapView
  re-renders on live updates, so Leaflet rebuilt every marker's DOM. Memoize.
- Popup now carries the provider's coverage note (ODIN: non-reporting
  utilities, e.g. Con Ed/O&R, show nothing even during an outage).

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

107 lines
4.0 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import type L from "leaflet";
import { c2api } from "@/lib/c2api";
import type { OutagesResponse } from "@/lib/types";
import type { BBox } from "@/lib/useCameras";
// Power outages from every provider an admin has enabled (GET /outages,
// server-26#184). The server caches each provider's feed; this only re-asks
// for a bbox. Polls while enabled and the tab is visible, and refetches as
// soon as a hidden tab comes back, so a wall left in a background tab never
// shows hours-old counts as current.
const POLL_MS = 3 * 60 * 1000;
const MOVE_DEBOUNCE_MS = 400;
const BBOX_PAD = 0.2;
export interface OutagesState {
/** null until the first response. */
data: OutagesResponse | null;
/** Last request failure (network/HTTP), cleared by the next success. */
error: string | null;
loaded: boolean;
}
function useOutageFetch(
getBox: (() => BBox | null) | null,
deps: unknown[],
subscribe?: (reload: () => void) => () => void,
): OutagesState {
const [data, setData] = useState<OutagesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const seq = useRef(0);
useEffect(() => {
// A new target (another incident, overlay re-enabled) must not be judged
// against the previous target's answer: drop it and show "checking…".
setData(null); setError(null); setLoaded(false);
if (!getBox) return;
const boxFn = getBox;
let last = 0;
const load = async () => {
const box = boxFn();
if (!box) return;
const mine = ++seq.current;
last = Date.now();
try {
const res = await c2api.getOutages(box);
if (mine !== seq.current) return; // a newer request's response wins
setData(res);
setError(null);
} catch (e) {
if (mine !== seq.current) return;
console.error("useOutages:", e);
setError(e instanceof Error ? e.message : String(e));
}
if (mine === seq.current) setLoaded(true);
};
const onVisible = () => { if (!document.hidden && Date.now() - last >= POLL_MS) load(); };
load();
const unsub = subscribe?.(load);
const poll = setInterval(() => { if (!document.hidden) load(); }, POLL_MS);
document.addEventListener("visibilitychange", onVisible);
return () => {
unsub?.();
clearInterval(poll);
document.removeEventListener("visibilitychange", onVisible);
seq.current++; // drop any in-flight response
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { data, error, loaded };
}
const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v));
/** Outages for the current map view, refetched on pan/zoom while `enabled` (the overlay is on). */
export function useMapOutages(map: L.Map, enabled: boolean): OutagesState {
return useOutageFetch(
enabled
? () => {
// Leaflet bounds run past ±180/±90 zoomed out; GET /outages answers 422 to that.
const b = map.getBounds().pad(BBOX_PAD);
return { south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180), north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180) };
}
: null,
[map, enabled],
(reload) => {
let t: ReturnType<typeof setTimeout> | undefined;
const onMove = () => { clearTimeout(t); t = setTimeout(reload, MOVE_DEBOUNCE_MS); };
map.on("moveend", onMove);
return () => { map.off("moveend", onMove); clearTimeout(t); };
},
);
}
/** Outages inside a fixed bbox (an incident, the wall's region). `null` = don't fetch. */
export function useOutagesIn(box: BBox | null): OutagesState {
// Key on a rounded box so a re-render with an equal-but-new object doesn't refetch.
const key = box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(3)).join(",") : "";
const boxRef = useRef(box);
boxRef.current = box;
return useOutageFetch(key ? () => boxRef.current : null, [key]);
}