The two hierarchical comparable reports — real-vs-real Compare and real-vs-budget — now let the user collapse or expand each top-level category's sub-categories. Groups start collapsed by default (#260): only the parent's subtotal row shows until the user expands it, and each expanded group is remembered per report. - New pure module utils/collapsibleRows.ts (visibility filter, group-key extraction, expanded-set (de)serialization) + useCollapsibleGroups hook wrapping the localStorage-persisted expanded set. Persisting the *expanded* set (not the collapsed one) makes "all collapsed" the zero/default state. - A chevron toggles each top-level parent; an "Expand all / Collapse all" button toggles them together. Section subtotals and result lines keep summing every leaf, so collapsing never changes any total. - Accessors mirror each table's own depth/parent logic so hidden rows are exactly a group's indented descendants. - i18n keys reports.collapse.{expandAll,collapseAll} (FR/EN); CHANGELOG. CategoryOverTimeTable (Trends -> by category) is intentionally left out: its rows are a flat top-N category list with no parent/child hierarchy to fold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86 lines
3.3 KiB
TypeScript
86 lines
3.3 KiB
TypeScript
/**
|
|
* 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<T> {
|
|
/** 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<T>(
|
|
rows: T[],
|
|
acc: CollapseAccessors<T>,
|
|
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<T>(rows: T[], acc: CollapseAccessors<T>): 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<string> {
|
|
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>): string {
|
|
return JSON.stringify([...expanded]);
|
|
}
|