// --------------------------------------------------------------------------- // Inbox lanes hook (sub-project 6, Phase 7 Task 19). // // Owns the four-lane payload (`rejected` / `candidates` / `unmatched` / // `done_today`) for the Inbox page. T19 replaced the original 5s polling // with the SP5 tail: the hook opens the claim and remittance streams via // `useTailStream`, and whenever the tail store grows (a new `item` event // arrived) it triggers a debounced refetch of the lanes payload. The // debounce coalesces a burst of events into one network round-trip. // // The streams themselves stay open as long as a consumer of this hook is // mounted (e.g. the `/inbox` page). When the user navigates away, the // hook unmounts and `useTailStream`'s cleanup aborts the SSE connection. // --------------------------------------------------------------------------- import { useCallback, useEffect, useRef, useState } from "react"; import { fetchInboxLanes, type InboxLanes } from "@/lib/inbox-api"; import { useTailStream } from "./useTailStream"; import { useTailStore } from "@/store/tail-store"; /** * Coalesce bursty tail events into a single network round-trip. With 5s * polling this was implicitly handled by the interval; with the tail we * have to do it explicitly. 250ms is short enough that the user sees the * new row within their natural attention span (well under the spec's * "live updates" SLO), and long enough that a 10-event burst doesn't * fire 10 fetches. */ const REFETCH_DEBOUNCE_MS = 250; export function useInboxLanes() { const [lanes, setLanes] = useState({ rejected: [], // SP14: the 5th working-surface lane (277CA STC A4/A6/A7). payer_rejected: [], candidates: [], unmatched: [], done_today: [], }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Keep the SP5 tail connections alive while this hook is mounted. The // hook itself doesn't read their return value — events are dispatched // into the tail store, which we subscribe to below. The `useTailStream` // call is what opens the SSE stream and keeps it alive; without these // two lines the inbox would silently stop updating once the user // navigated away from a page that already mounted a tail. useTailStream("claims"); useTailStream("remittances"); // Subscribe to the tail-store order arrays. We watch the *length* (not // the contents) because the store's `addClaim`/`addRemittance` returns // a new array reference on every insert, which is exactly what zustand // uses to flag a re-render. const claimOrderLen = useTailStore((s) => s.claimOrder.length); const remitOrderLen = useTailStore((s) => s.remitOrder.length); const refetch = useCallback(async () => { try { const next = await fetchInboxLanes(); setLanes(next); setError(null); } catch (e) { setError(e as Error); } finally { setLoading(false); } }, []); // Hold the latest `refetch` in a ref so the debounce timer always // closes over the current closure without forcing the effect below to // re-subscribe on every render. const refetchRef = useRef(refetch); refetchRef.current = refetch; // Track the order lengths we last observed so the very first render // (with the store already at its initial size, possibly non-zero if a // sibling page has been populating it) doesn't trigger a spurious // refetch. -1 sentinel means "haven't observed yet". const prevLenRef = useRef<{ claims: number; remits: number }>({ claims: -1, remits: -1, }); const debounceRef = useRef | null>(null); useEffect(() => { const prev = prevLenRef.current; const claimsChanged = prev.claims !== claimOrderLen; const remitsChanged = prev.remits !== remitOrderLen; prevLenRef.current = { claims: claimOrderLen, remits: remitOrderLen }; // Skip the initial observation — the lanes have already been // fetched via the mount effect below. Only react to *changes*. if (prev.claims === -1) return; if (!claimsChanged && !remitsChanged) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { debounceRef.current = null; void refetchRef.current(); }, REFETCH_DEBOUNCE_MS); return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); debounceRef.current = null; } }; }, [claimOrderLen, remitOrderLen]); // Initial fetch on mount. Runs once; the tail-driven effect above // handles subsequent updates. useEffect(() => { void refetch(); }, [refetch]); return { lanes, loading, error, refetch }; }