// --------------------------------------------------------------------------- // 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. // - SP25: the `id` type is widened to `string | number` because `Ack` // and `Ta1Ack` use numeric database ids while `Claim` / `Remittance` // use string ids. Dedup normalizes via `String(...)`. // - SP28: `ClaimAck` adds another numeric-id slice (`claimAcks` / // `claimAckOrder`). Same dedup normalization applies — `String(id)` // keeps the symmetric comparison the hook has always used. // --------------------------------------------------------------------------- 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[]; // SP25: same keyed-by-id shape as claims/remittances, just with // numeric ids. The store keys the dict by String(id); the order // array carries the numeric id for arrival-order iteration. case "acks": { const out: unknown[] = []; for (const id of s.ackOrder) { const v = s.acks[String(id)]; if (v !== undefined) out.push(v); } return out; } case "ta1_acks": { const out: unknown[] = []; for (const id of s.ta1AckOrder) { const v = s.ta1Acks[String(id)]; if (v !== undefined) out.push(v); } return out; } // SP28: the claim↔ack link slice. Same keyed-by-id shape as // acks/ta1_acks (numeric id from the `claim_acks` table), so the // dispatch is symmetric — the page-specific filter (e.g. // `link.claimId === claimId`) is applied AFTER dedup so the // base list's rows aren't accidentally dropped. case "claim-acks": { const out: unknown[] = []; for (const id of s.claimAckOrder) { const v = s.claimAcks[String(id)]; if (v !== undefined) out.push(v); } return out; } } }); // Normalize ids to strings so numeric (Ack/Ta1Ack) and string // (Claim/Remittance/Activity) ids dedup correctly against each other. // (The hook is generic over `T extends { id: string | number }`, but // the store's dedup set is a Set internally — using the // string form here keeps the comparison symmetric.) const baseIds = new Set(); for (const b of baseItems) baseIds.add(String(b.id)); const tailAfterDedup: unknown[] = []; for (const t of tailSlice) { const id = String((t as { id: string | number }).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[])]; }