/** * Collapse/expand of top-level category groups in the hierarchical comparable * reports (real-vs-real Compare + Budget-vs-Actual) — issue #254. * * Pure helpers only; the React state + persistence glue lives in the * `useCollapsibleGroups` hook. * * A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps * its own subtotal row visible but hides its entire subtree (every following * depth ≥ 1 row until the next depth-0 row). Rows must arrive in depth-first * order — the order the compare/budget services already emit them in, and the * same order `reorderRows` relies on. * * The accessors are passed in rather than read off fixed field names so each * table can supply the *exact* depth expression it renders with (CategoryDelta * uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from * `parent_id`). Keeping the collapse depth and the indentation depth identical * guarantees the hidden rows are exactly the indented descendants. */ /** How the collapse logic reads hierarchy position + identity off a row. */ export interface CollapseAccessors { /** Stable per-group key (a category id, stringified). */ keyOf: (row: T) => string; /** Indentation depth; 0 = top-level. Must match the rendered indentation. */ depthOf: (row: T) => number; /** True when the row is a group header / subtotal (has children). */ isParent: (row: T) => boolean; } /** * Keeps every row that is currently visible given which top-level groups are * collapsed. `isCollapsed` is consulted only for top-level parent rows; a * collapsed one keeps its own (subtotal) row and drops its whole subtree. */ export function visibleRows( rows: T[], acc: CollapseAccessors, isCollapsed: (row: T) => boolean, ): T[] { const out: T[] = []; // While true we are inside a collapsed top-level parent's subtree and drop // every deeper row until the next depth-0 row re-opens the flow. let hidingSubtree = false; for (const row of rows) { if (acc.depthOf(row) === 0) { out.push(row); hidingSubtree = acc.isParent(row) && isCollapsed(row); } else if (!hidingSubtree) { out.push(row); } } return out; } /** Keys of every collapsible group (top-level parent rows), in encounter order. */ export function collapsibleKeys(rows: T[], acc: CollapseAccessors): string[] { const keys: string[] = []; for (const row of rows) { if (acc.depthOf(row) === 0 && acc.isParent(row)) keys.push(acc.keyOf(row)); } return keys; } /** * Parses the persisted value into the set of *expanded* group keys. The report * default is "everything collapsed" (issue #254/#260), so an absent or corrupt * value yields an empty set — i.e. all groups collapsed on a first-ever visit. */ export function parseStoredExpanded(raw: string | null): Set { if (!raw) return new Set(); try { const parsed: unknown = JSON.parse(raw); if (Array.isArray(parsed)) { return new Set(parsed.filter((k): k is string => typeof k === "string")); } } catch { // Corrupt value: fall back to the collapsed default. } return new Set(); } /** Serialises the set of expanded group keys for persistence. */ export function serializeExpanded(expanded: Set): string { return JSON.stringify([...expanded]); }