Generalize the report category collapse from level-1-only to every hierarchy level, on the three hierarchical report tables (real-vs-real Compare, real-vs-budget Compare, Trends by category). Visibility is now decided by an ancestor walk, not by row adjacency, so it is independent of row order (the level-ordered budget grid emits a non-DFS order). - collapsibleRows: rewrite visibleRows as an ancestor walk (a row is hidden iff any ancestor is collapsed); add parentKeyOf + injective `p:` keys; collapsibleKeys returns all parents (any depth); extract the pure, tested isCollapsedFor polarity helper; MAX_TREE_DEPTH cycle guard. - useCollapsibleGroups: persist in user_preferences (per-profile, destroyed with the profile) instead of localStorage; storageKey nullable (no persistence); options.defaultExpanded; async hydration (no flash); collapseAll(rows). - 3 tables: fix BOTH gates (collapsed flag + button) isTopParent -> isParent, add parentKeyOf accessors, aria-level on parent rows. - Delete dead CategoryTable.tsx (0 imports). - Tests: rewrite collapsibleRows.test.ts (BFS==DFS masking, cycle guard, cross-section ancestor, "(direct)" leaf, polarity); extend overTimeTableModel fixture to 3 levels with cascade assertions. Collapse stays purely visual: subtotals and result lines are computed from raw rows, never from visible rows. Resolves #288 Generated autonomously by /autopilot run of 2026-07-15 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90 lines
3.5 KiB
TypeScript
90 lines
3.5 KiB
TypeScript
import type { OverTimeRow } from "../../shared/types";
|
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
|
import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
|
|
|
|
/**
|
|
* Render-model helpers for the hierarchical trends table (issue #265). Kept in a
|
|
* pure module — the project has no React render harness, so the grouping and
|
|
* result-interleaving logic is unit-tested here while `CategoryOverTimeTable`
|
|
* stays a thin renderer.
|
|
*/
|
|
|
|
/**
|
|
* Collapse groups keyed by category id; a row is hidden when any ancestor
|
|
* (walking parent_id) is collapsed, so every parent level is collapsible. The
|
|
* `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these
|
|
* accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged.
|
|
*/
|
|
export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = {
|
|
keyOf: (row) => `p:${row.categoryId}`,
|
|
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
|
|
depthOf: (row) => row.depth,
|
|
isParent: (row) => row.is_parent,
|
|
};
|
|
|
|
/** Distinct localStorage key so the trends expansion state never collides with
|
|
* the comparable tables' `reports-compare-expanded` (issue #265). */
|
|
export const OVERTIME_EXPANDED_STORAGE_KEY = "reports-trends-expanded";
|
|
|
|
/**
|
|
* One rendered section: its type, the FULL hierarchy rows of that type (parents
|
|
* + leaves, parent-first depth-first — straight off `data.tree`), and the
|
|
* leaf-summed per-month + total subtotals carried over from the reducer.
|
|
*
|
|
* `rows` carries the parents (unlike `OverTimeSection.rows`, which is leaves
|
|
* only) so the table can render the hierarchy and collapse it; `monthly`/`total`
|
|
* stay the reducer's leaf sums, so they are unaffected by which rows are folded
|
|
* away — the "collapse is purely visual" invariant.
|
|
*/
|
|
export interface OverTimeRenderSection {
|
|
type: OverTimeType;
|
|
rows: OverTimeRow[];
|
|
/** month (YYYY-MM) -> section subtotal (leaf-summed, collapse-invariant). */
|
|
monthly: Record<string, number>;
|
|
/** sum across every month (leaf-summed, collapse-invariant). */
|
|
total: number;
|
|
}
|
|
|
|
const typeOf = (row: OverTimeRow): OverTimeType => {
|
|
const t = row.category_type;
|
|
return t === "income" || t === "transfer" ? t : "expense";
|
|
};
|
|
|
|
/**
|
|
* Splits the id-keyed tree into render sections, driven by the reducer's own
|
|
* (ordered, non-empty, income → expense → transfer) `analysis.sections` so the
|
|
* ordering and the subtotals match `computeOverTimeResults` exactly. Each
|
|
* section is given the FULL hierarchy rows of its type (from `tree`), while its
|
|
* subtotals stay the reducer's leaf sums.
|
|
*
|
|
* Returns the non-transfer sections and the transfer section separately so the
|
|
* caller can interleave the "result before transfers" line between them
|
|
* (parity with `ComparePeriodTable`).
|
|
*/
|
|
export function groupOverTimeSections(
|
|
analysis: OverTimeAnalysis,
|
|
tree: OverTimeRow[],
|
|
): {
|
|
nonTransferSections: OverTimeRenderSection[];
|
|
transferSection: OverTimeRenderSection | null;
|
|
} {
|
|
// Full hierarchy rows (parents + leaves) bucketed by type, tree order kept.
|
|
const rowsByType: Record<OverTimeType, OverTimeRow[]> = {
|
|
income: [],
|
|
expense: [],
|
|
transfer: [],
|
|
};
|
|
for (const row of tree) rowsByType[typeOf(row)].push(row);
|
|
|
|
const sections: OverTimeRenderSection[] = analysis.sections.map((s) => ({
|
|
type: s.type,
|
|
rows: rowsByType[s.type],
|
|
monthly: s.monthly,
|
|
total: s.total,
|
|
}));
|
|
|
|
return {
|
|
nonTransferSections: sections.filter((s) => s.type !== "transfer"),
|
|
transferSection: sections.find((s) => s.type === "transfer") ?? null,
|
|
};
|
|
}
|