121 lines
5.0 KiB
TypeScript
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[])];
|
|
}
|