From 191bc935c3804ab9e4dde5ebfa69935ceedbab26 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 09:09:53 -0600 Subject: [PATCH] feat(sp25): extend useMergedTail to handle acks + ta1_acks slices The Acks page needs two merge hooks (one per ack flavor) and both shapes use numeric database ids rather than the string ids that Claim/Remittance/Activity use. The generic constraint widens to `T extends { id: string | number }` and the dedup Set normalizes to String(id) so a base item with id=2 and a tail item with id=2 collide correctly. Adds two switch cases that mirror the claims/remittances keyed-by-id pattern: iterate the order array, look up the matching value in the dict, drop undefined holes defensively. Two tests assert the new slices order by their order arrays and dedup against base items. --- src/hooks/useMergedTail.test.ts | 78 ++++++++++++++++++++++++++++++++- src/hooks/useMergedTail.ts | 33 ++++++++++++-- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/hooks/useMergedTail.test.ts b/src/hooks/useMergedTail.test.ts index 82d025b..42e0a2a 100644 --- a/src/hooks/useMergedTail.test.ts +++ b/src/hooks/useMergedTail.test.ts @@ -7,7 +7,7 @@ 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"; +import type { Ack, Claim, Remittance, Activity, Ta1Ack } from "@/types"; /** * Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`, @@ -88,6 +88,34 @@ function activity(id: string, message: string): Activity { }; } +function ack(id: number, sourceBatchId: string): Ack { + return { + id, + sourceBatchId, + acceptedCount: 1, + rejectedCount: 0, + receivedCount: 1, + ackCode: "A", + parsedAt: "2026-07-02T12:00:00Z", + patientControlNumber: `PCN-${id}`, + }; +} + +function ta1Ack(id: number, controlNumber: string): Ta1Ack { + return { + id, + controlNumber, + ackCode: "A", + noteCode: "000", + interchangeDate: "2026-07-02", + interchangeTime: "1200", + senderId: "S", + receiverId: "R", + sourceBatchId: `TA1-${id}`, + parsedAt: "2026-07-02T12:00:00Z", + }; +} + describe("useMergedTail", () => { beforeEach(() => { // Singleton store — clear each slice between tests so cases are @@ -95,6 +123,8 @@ describe("useMergedTail", () => { useTailStore.getState().reset("claims"); useTailStore.getState().reset("remittances"); useTailStore.getState().reset("activity"); + useTailStore.getState().reset("acks"); + useTailStore.getState().reset("ta1_acks"); }); it("test_merges_base_and_tail_by_id_base_first", () => { @@ -228,4 +258,50 @@ describe("useMergedTail", () => { unmount(); }); + + // ------------------------------------------------------------------------- + // SP25: the Acks page mounts two merge hooks — one per ack flavor. + // Both have numeric ids (database row ids) instead of string ids, so + // the dedup must compare via String(id) to keep the keyed-by-id + // contract intact. + // ------------------------------------------------------------------------- + + it("test_acks_resource_orders_by_ack_order_and_dedupes", () => { + const base = [ack(1, "BASE-A"), ack(2, "BASE-B")]; + const { addAck } = useTailStore.getState(); + addAck(ack(2, "REPLAY-B")); // duplicate id — must be dropped + addAck(ack(3, "TAIL-C")); + addAck(ack(4, "TAIL-D")); + + const { result, unmount } = renderHook(() => + useMergedTail("acks", base), + ); + + const merged = result.current ?? []; + // Base first, then tail in arrival order. Tail's "2" is dropped + // because the base already has id 2. + expect(merged.map((a) => a.id)).toEqual([1, 2, 3, 4]); + // Base version wins for id 2. + expect(merged[1]?.sourceBatchId).toBe("BASE-B"); + + unmount(); + }); + + it("test_ta1_acks_resource_orders_by_ta1_ack_order_and_dedupes", () => { + const base = [ta1Ack(1, "000000001")]; + const { addTa1Ack } = useTailStore.getState(); + addTa1Ack(ta1Ack(2, "000000002")); + addTa1Ack(ta1Ack(3, "000000003")); + + const { result, unmount } = renderHook(() => + useMergedTail("ta1_acks", base), + ); + + const merged = result.current ?? []; + expect(merged.map((a) => a.id)).toEqual([1, 2, 3]); + expect(merged[0]?.controlNumber).toBe("000000001"); + expect(merged[1]?.controlNumber).toBe("000000002"); + + unmount(); + }); }); diff --git a/src/hooks/useMergedTail.ts b/src/hooks/useMergedTail.ts index b1dca07..e49c825 100644 --- a/src/hooks/useMergedTail.ts +++ b/src/hooks/useMergedTail.ts @@ -17,12 +17,15 @@ // 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(...)`. // --------------------------------------------------------------------------- import { useTailStore } from "@/store/tail-store"; import type { TailResource } from "@/lib/tail-stream"; -export function useMergedTail( +export function useMergedTail( resource: TailResource, baseItems: T[], filterFn?: (item: T) => boolean, @@ -56,15 +59,39 @@ export function useMergedTail( // 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; + } } }); + // 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(b.id); + for (const b of baseItems) baseIds.add(String(b.id)); const tailAfterDedup: unknown[] = []; for (const t of tailSlice) { - const id = (t as { id: string }).id; + const id = String((t as { id: string | number }).id); if (baseIds.has(id)) continue; tailAfterDedup.push(t); }