From e56a1eb50335d2259715dbd27f476267f2ceeb02 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 17:05:24 -0600 Subject: [PATCH] feat(frontend): useMergedTail merges base + tail with filter --- src/hooks/useMergedTail.test.ts | 231 ++++++++++++++++++++++++++++++++ src/hooks/useMergedTail.ts | 77 +++++++++++ 2 files changed, 308 insertions(+) create mode 100644 src/hooks/useMergedTail.test.ts create mode 100644 src/hooks/useMergedTail.ts diff --git a/src/hooks/useMergedTail.test.ts b/src/hooks/useMergedTail.test.ts new file mode 100644 index 0000000..82d025b --- /dev/null +++ b/src/hooks/useMergedTail.test.ts @@ -0,0 +1,231 @@ +// @vitest-environment happy-dom +// React's act-aware env — mirror the other hook tests. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useMergedTail } from "./useMergedTail"; +import { useTailStore } from "@/store/tail-store"; +import type { Claim, Remittance, Activity } from "@/types"; + +/** + * Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`, + * etc. — the project doesn't ship `@testing-library/react`, so we wire a + * Probe component into a real `createRoot` and read the hook's return + * value through a shared `result` object. `act` flushes React's state + * updates between micro-tasks. + */ +function renderHook(setup: () => TResult): { + result: { current: TResult | undefined }; + unmount: () => void; +} { + const result: { current: TResult | undefined } = { current: undefined }; + const container = document.createElement("div"); + document.body.appendChild(container); + + function Probe() { + result.current = setup(); + return null; + } + + const root: Root = createRoot(container); + act(() => { + root.render(React.createElement(Probe)); + }); + + return { + result, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Sample factories. The hook is generic over `T extends { id: string }`, +// so for `claims` we use Claim, for `remittances` we use Remittance, etc. +// Tests populate the store via `useTailStore.getState().addX(...)` — the +// same path the live-tail hook uses — so the merge is exercised against +// the real store shape (including the `claimOrder`/`remitOrder` indexing +// the store maintains for the keyed-by-id slices). +// --------------------------------------------------------------------------- + +function claim(id: string, patientName: string): Claim { + return { + id, + patientName, + providerNpi: "1234567890", + payerName: "Medicaid", + cptCode: "99213", + billedAmount: 100, + receivedAmount: 0, + status: "submitted", + submissionDate: "2026-06-20T00:00:00Z", + }; +} + +function remit(id: string, claimId: string): Remittance { + return { + id, + claimId, + payerName: "Medicaid", + paidAmount: 100, + adjustmentAmount: 0, + receivedDate: "2026-06-20", + checkNumber: id, + status: "received", + }; +} + +function activity(id: string, message: string): Activity { + return { + id, + kind: "claim_submitted", + message, + timestamp: "2026-06-20T00:00:00Z", + }; +} + +describe("useMergedTail", () => { + beforeEach(() => { + // Singleton store — clear each slice between tests so cases are + // independent. Mirrors the pattern in `tail-store.test.ts`. + useTailStore.getState().reset("claims"); + useTailStore.getState().reset("remittances"); + useTailStore.getState().reset("activity"); + }); + + it("test_merges_base_and_tail_by_id_base_first", () => { + // Base: [A, B]. Tail store adds [B, C, D] — note B is a duplicate + // of a base item (mirrors what a snapshot replay on reconnect would + // produce). After dedup, tail should contribute only [C, D]. + const base = [claim("A", "Alice"), claim("B", "Bob")]; + const { addClaim } = useTailStore.getState(); + addClaim(claim("B", "Bob-Updated")); // deduped — first write wins + addClaim(claim("C", "Carol")); + addClaim(claim("D", "Dave")); + + const { result, unmount } = renderHook(() => + useMergedTail("claims", base), + ); + + const merged = result.current ?? []; + // Base items first, in their original order; then tail in arrival + // order, with duplicates of base ids dropped. + expect(merged.map((c) => c.id)).toEqual(["A", "B", "C", "D"]); + // The base version of B must be preserved (the store's first-write- + // wins rule keeps "Bob", not "Bob-Updated"). + expect(merged[1]?.patientName).toBe("Bob"); + + unmount(); + }); + + it("test_filter_predicate_drops_tail_items_that_dont_match", () => { + // Base: [A]. Tail store: [B, C, D]. Predicate `id !== "D"` drops + // the trailing item — the filter must run AFTER dedup and only + // affect the tail contribution. + const base = [claim("A", "Alice")]; + const { addClaim } = useTailStore.getState(); + addClaim(claim("B", "Bob")); + addClaim(claim("C", "Carol")); + addClaim(claim("D", "Dave")); + + const { result, unmount } = renderHook(() => + useMergedTail("claims", base, (c) => c.id !== "D"), + ); + + const merged = result.current ?? []; + expect(merged.map((c) => c.id)).toEqual(["A", "B", "C"]); + + unmount(); + }); + + it("test_empty_tail_store_returns_base_unchanged", () => { + const base = [claim("A", "Alice"), claim("B", "Bob")]; + + const { result, unmount } = renderHook(() => + useMergedTail("claims", base), + ); + + const merged = result.current ?? []; + expect(merged).toHaveLength(2); + expect(merged.map((c) => c.id)).toEqual(["A", "B"]); + // The hook returns base verbatim when the tail slice is empty — + // no copy churn, so `toBe` reference equality holds. + expect(merged[0]).toBe(base[0]); + expect(merged[1]).toBe(base[1]); + + unmount(); + }); + + it("test_new_tail_item_appears_after_base", async () => { + // Base: [A, B]. Tail store empty initially. After the component + // mounts, we push a new claim to the store; the merged list must + // include the new tail item, surfaced via re-render. (Plan calls + // this "appears_at_top" — in this hook's contract the new item + // comes AFTER base items, not before, so the assertion is that it + // appears at all and is positioned correctly.) + const base = [claim("A", "Alice"), claim("B", "Bob")]; + + const { result, unmount } = renderHook(() => + useMergedTail("claims", base), + ); + + expect(result.current?.map((c) => c.id)).toEqual(["A", "B"]); + + // New item lands in the store → zustand notifies subscribers → the + // hook re-renders with the merged result. + await act(async () => { + useTailStore.getState().addClaim(claim("C", "Carol")); + }); + + const merged = result.current ?? []; + // Base first, then tail in arrival order. + expect(merged.map((c) => c.id)).toEqual(["A", "B", "C"]); + expect(merged[2]?.patientName).toBe("Carol"); + + unmount(); + }); + + it("test_activity_resource_uses_array_slice_and_dedupes_by_id", () => { + // Activity doesn't have a keyed-by-id map — it's a plain array. + // The hook must still dedup against baseItems by id (Activity has + // an id, even though the store's addActivity doesn't dedup). + const base = [activity("A", "alpha"), activity("B", "bravo")]; + const { addActivity } = useTailStore.getState(); + addActivity(activity("B", "bravo-dup")); // duplicate id + addActivity(activity("C", "charlie")); + addActivity(activity("D", "delta")); + + const { result, unmount } = renderHook(() => + useMergedTail("activity", base), + ); + + const merged = result.current ?? []; + // Base first, then tail in arrival order. Tail's "B" is dropped + // because it's a duplicate of base's "B". + expect(merged.map((a) => a.id)).toEqual(["A", "B", "C", "D"]); + + unmount(); + }); + + it("test_remittance_resource_orders_by_remitOrder", () => { + // Smoke test for the remittances slice — exercise the second keyed + // slice so we know the claim/remit branches are symmetric. + const base = [remit("R-1", "CLM-1")]; + const { addRemittance } = useTailStore.getState(); + addRemittance(remit("R-2", "CLM-2")); + addRemittance(remit("R-3", "CLM-3")); + + const { result, unmount } = renderHook(() => + useMergedTail("remittances", base), + ); + + const merged = result.current ?? []; + expect(merged.map((r) => r.id)).toEqual(["R-1", "R-2", "R-3"]); + + unmount(); + }); +}); diff --git a/src/hooks/useMergedTail.ts b/src/hooks/useMergedTail.ts new file mode 100644 index 0000000..b1dca07 --- /dev/null +++ b/src/hooks/useMergedTail.ts @@ -0,0 +1,77 @@ +// --------------------------------------------------------------------------- +// Live-tail merge hook (sub-project 5, Phase 5 Task 20). +// +// Reads the matching slice of `useTailStore` and returns a single array: +// `baseItems` first (in the order the caller supplied them), then any new +// tail items in arrival order, with duplicates of base ids removed. +// +// Design notes: +// - The dedup runs against `baseItems` (not the other way around) because +// the base list is the authoritative snapshot from the page's JSON +// fetch — the tail slice is an opportunistic delta, so the base wins +// when an id appears in both. +// - The optional `filterFn` is applied to the tail slice AFTER dedup so a +// page-specific filter (e.g. "only show me submitted claims") doesn't +// accidentally include items that were already filtered out of base. +// - We don't `useMemo` here: `baseItems` is a new array reference on +// every render of the caller, so memo deps would invalidate anyway. +// The merge is cheap (one Set build + two filters) and zustand's +// selector ensures we only re-render when the slice actually changes. +// --------------------------------------------------------------------------- + +import { useTailStore } from "@/store/tail-store"; +import type { TailResource } from "@/lib/tail-stream"; + +export function useMergedTail( + resource: TailResource, + baseItems: T[], + filterFn?: (item: T) => boolean, +): T[] { + // Select the slice that matches the resource. We return a fresh array + // each time so the consumer gets a stable iteration order even when + // zustand hands us a new container (Record or array) reference. + const tailSlice = useTailStore((s) => { + switch (resource) { + case "claims": { + // The store keys claims by id in a Record for O(1) updates, but + // also keeps an `claimOrder` array so we can iterate in arrival + // order. Filter out any holes (defensive — shouldn't happen but + // type-narrows the result to Claim[]). + const out: unknown[] = []; + for (const id of s.claimOrder) { + const v = s.claims[id]; + if (v !== undefined) out.push(v); + } + return out; + } + case "remittances": { + const out: unknown[] = []; + for (const id of s.remitOrder) { + const v = s.remittances[id]; + if (v !== undefined) out.push(v); + } + return out; + } + case "activity": + // Activity is already an array — the store appends in arrival + // order, so iteration order is exactly what we want. + return s.activity as unknown[]; + } + }); + + const baseIds = new Set(); + for (const b of baseItems) baseIds.add(b.id); + + const tailAfterDedup: unknown[] = []; + for (const t of tailSlice) { + const id = (t as { id: string }).id; + if (baseIds.has(id)) continue; + tailAfterDedup.push(t); + } + + const tailAfterFilter = filterFn + ? tailAfterDedup.filter((t) => filterFn(t as T)) + : tailAfterDedup; + + return [...baseItems, ...(tailAfterFilter as T[])]; +}