Simpl-Resultat/src/components/reports/overTimeTableModel.ts
le king fu 48adb3db77
All checks were successful
PR Check / rust (pull_request) Successful in 23m0s
PR Check / frontend (pull_request) Successful in 2m30s
feat(reports): collapse category hierarchy at every level (socle + 3 reports)
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>
2026-07-15 20:58:18 -04:00

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,
};
}