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; depth/parent mirror the tree the render * indents by, so a collapsed group hides exactly its indented descendants. The * `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these * accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged. */ export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors = { keyOf: (row) => String(row.categoryId), 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; /** 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 = { 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, }; }