Files
cyclone/src/hooks/useMergedTail.ts
T

78 lines
3.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.
// ---------------------------------------------------------------------------
import { useTailStore } from "@/store/tail-store";
import type { TailResource } from "@/lib/tail-stream";
export function useMergedTail<T extends { id: string }>(
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<string>();
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[])];
}