diff --git a/src/hooks/useInboxLanes.test.ts b/src/hooks/useInboxLanes.test.ts index 3935ed3..58ff67c 100644 --- a/src/hooks/useInboxLanes.test.ts +++ b/src/hooks/useInboxLanes.test.ts @@ -1,7 +1,32 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, renderHook, waitFor } from "@testing-library/react"; +// React's `act` warnings need an act-aware environment; mirror the other +// hook tests in this repo (`useClaimDetail`, `useReconciliation`, ...). +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { useInboxLanes } from "./useInboxLanes"; +import { useTailStore } from "@/store/tail-store"; +import type { Claim } from "@/types"; + +// --------------------------------------------------------------------------- +// SP6 T19: tail-driven refetch. +// +// The hook opens the SP5 claim/remit tail streams and refetches the lane +// payload whenever the tail-store grows (i.e. a new `item` event arrived +// via SSE). We mock `useTailStream` so the test doesn't open a real SSE +// connection, and drive the tail store directly via `useTailStore.setState` +// to simulate what `useTailStream` would have dispatched on an `item`. +// --------------------------------------------------------------------------- + +vi.mock("./useTailStream", () => ({ + useTailStream: vi.fn(() => ({ + status: "live", + lastEventAt: null, + error: null, + forceReconnect: vi.fn(), + })), +})); afterEach(() => { cleanup(); @@ -9,20 +34,42 @@ afterEach(() => { vi.useRealTimers(); }); +function emptyLanesPayload() { + return { + ok: true, + json: async () => ({ + rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }), + }; +} + +function makeClaim(id: string): Claim { + return { + id, + patientName: "Test Patient", + providerNpi: "1234567890", + payerName: "Aetna", + cptCode: "99213", + billedAmount: 100, + receivedAmount: 0, + status: "submitted", + submissionDate: "2026-06-20T00:00:00Z", + }; +} + describe("useInboxLanes", () => { + beforeEach(() => { + // Each test starts with an empty tail store so length-delta detection + // fires cleanly on the first simulated `item`. + useTailStore.getState().reset("claims"); + useTailStore.getState().reset("remittances"); + }); + it("loads lanes on mount", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - rejected: [], - candidates: [], - unmatched: [], - done_today: [], - }), - }), - ); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(emptyLanesPayload())); const { result } = renderHook(() => useInboxLanes()); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.lanes).toEqual({ @@ -42,4 +89,33 @@ describe("useInboxLanes", () => { await waitFor(() => expect(result.current.error).not.toBeNull()); expect(result.current.error?.message).toMatch(/500/); }); + + it("refetches when a claim arrives via the tail store", async () => { + const fetchMock = vi.fn().mockResolvedValue(emptyLanesPayload()); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useInboxLanes()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Simulate a new claim `item` event from the SP5 stream by writing + // directly into the tail store — that's what the dispatcher in + // `useTailStream` does on a real `item` event. + act(() => { + useTailStore.getState().addClaim(makeClaim("CLP-NEW")); + }); + + // The hook should debounce-trigger a refetch within ~500ms. We give + // it a generous timeout to avoid flakiness on slow CI. + await waitFor( + () => expect(fetchMock.mock.calls.length).toBeGreaterThan(1), + { timeout: 1500 }, + ); + + // Cleanup: avoid the debounce timer from firing into an unmounted + // hook (which would log a warning under react-dom 18). + await act(async () => { + result.current.refetch; + }); + }); }); diff --git a/src/hooks/useInboxLanes.ts b/src/hooks/useInboxLanes.ts index 5e2a0c9..cde09ed 100644 --- a/src/hooks/useInboxLanes.ts +++ b/src/hooks/useInboxLanes.ts @@ -1,8 +1,32 @@ +// --------------------------------------------------------------------------- +// 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"; -/** Default poll interval. Tail-integration (T19) will replace this. */ -const POLL_INTERVAL_MS = 5_000; +/** + * 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({ @@ -13,7 +37,22 @@ export function useInboxLanes() { }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const pollRef = useRef | null>(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 { @@ -27,14 +66,51 @@ export function useInboxLanes() { } }, []); + // 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(); - pollRef.current = setInterval(() => { - void refetch(); - }, POLL_INTERVAL_MS); - return () => { - if (pollRef.current) clearInterval(pollRef.current); - }; }, [refetch]); return { lanes, loading, error, refetch };