Renders the trends-by-category table from the #264 id-keyed tree as an indented parent/child hierarchy (parity with ComparePeriodTable): top-level parents carry a chevron and fold their subtree down to a subtotal, with an "expand all / collapse all" button. Collapsed by default; expansion state is persisted under a trends-specific localStorage key (reports-trends-expanded), distinct from the comparable tables. The "result before transfers" line is interleaved before the transfers section instead of sitting at the bottom. Collapse is purely visual: section subtotals, before/net results and totals come from computeOverTimeResults over the raw tree, never the visible rows. New pure module overTimeTableModel.ts (section grouping + collapse accessors) keeps the component thin and unit-testable without a React render harness; overTimeResults.ts is left untouched. 8 new tests; build + 728 vitest green. Resolves #265 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
3.5 KiB
TypeScript
89 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; 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<OverTimeRow> = {
|
|
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<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,
|
|
};
|
|
}
|