Files
cyclone/src/hooks/useMergedTail.ts
T

121 lines
5.0 KiB
TypeScript

// ---------------------------------------------------------------------------
// 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<T extends { id: string | number }>(
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<string> internally — using the
// string form here keeps the comparison symmetric.)
const baseIds = new Set<string>();
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[])];
}