feat(reports): CategoryOverTimeTable hierarchique + collapse + resultat interleave (#265) #269

Closed
maximus wants to merge 1 commit from issue-265-overtime-hierarchy into issue-264-overtime-tree
5 changed files with 456 additions and 107 deletions

View file

@ -5,6 +5,7 @@
### Ajouté
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
### Corrigé

View file

@ -5,6 +5,7 @@
### Added
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
### Fixed

View file

@ -1,7 +1,15 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import type { CategoryOverTimeData } from "../../shared/types";
import { computeOverTimeResults, type OverTimeType } from "./overTimeResults";
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
type OverTimeRenderSection,
} from "./overTimeTableModel";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
@ -38,6 +46,14 @@ interface CategoryOverTimeTableProps {
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
const { t } = useTranslation();
// Collapse/expand of sub-category groups — collapsed by default (issue #265),
// persisted under a key distinct from the comparable tables. Called before the
// early return so the hook order stays stable.
const groups = useCollapsibleGroups<OverTimeRow>(
OVERTIME_EXPANDED_STORAGE_KEY,
OVERTIME_COLLAPSE_ACCESSORS,
);
if (data.data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -48,11 +64,161 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
// the result lines are exact, and homonym categories never collide.
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data);
const analysis = computeOverTimeResults(data);
const { months, hasTransfers, net, beforeTransfers } = analysis;
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
const colSpan = months.length + 2;
const hasGroups = groups.groupCount(data.tree) > 0;
const allExpanded = groups.allExpanded(data.tree);
// A section = its header, its (visibly) indented hierarchy rows, and a
// leaf-summed subtotal row. Only top-level parents collapse (parity with the
// comparable tables); the subtotal is always the reducer's leaf sum, so it
// never changes when a group is folded away.
const renderSection = (section: OverTimeRenderSection) => (
<Fragment key={section.type}>
{/* Section header */}
<tr className="bg-[var(--muted)]">
<td
colSpan={colSpan}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{t(SECTION_LABEL_KEY[section.type])}
</td>
</tr>
{/* Category rows — neutral leaf/parent magnitudes (not deltas) */}
{groups.visible(section.rows).map((row) => {
const isParent = row.is_parent;
const depth = row.depth;
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
: isIntermediateParent
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
: "hover:bg-[var(--muted)]/40"
}`}
>
<td
className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}
>
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</button>
) : (
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
)}
</td>
{months.map((month, monthIdx) => {
const value = row.monthly[monthIdx] ?? 0;
return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(row.total)}
</td>
</tr>
);
})}
{/* Section subtotal */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(SECTION_TOTAL_KEY[section.type])}
</td>
{months.map((month) => (
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
{cadFormatter(section.monthly[month])}
</td>
))}
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(section.total)}
</td>
</tr>
</Fragment>
);
// A result line (before-transfers subtotal or the net bottom line). Amounts are
// coloured by sign (surplus green / deficit red). Computed from the raw tree,
// so folding groups never moves these figures.
const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
<tr
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
strong ? "border-t-2 border-[var(--border)]" : "border-b border-[var(--border)]"
}`}
>
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t(labelKey)}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(series.monthly[month]) }}
>
{cadFormatter(series.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(series.total) }}
>
{cadFormatter(series.total)}
</td>
</tr>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{hasGroups && (
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data.tree))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
@ -71,110 +237,14 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
</tr>
</thead>
<tbody>
{sections.map((section) => (
<Fragment key={section.type}>
{/* Section header */}
<tr className="bg-[var(--muted)]">
<td
colSpan={colSpan}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{t(SECTION_LABEL_KEY[section.type])}
</td>
</tr>
{/* Category rows — neutral leaf magnitudes (not deltas) */}
{section.rows.map((row, rowIdx) => (
<tr
key={`${row.categoryId ?? "u"}-${rowIdx}`}
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
{months.map((month, monthIdx) => {
const value = row.monthly[monthIdx] ?? 0;
return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(row.total)}
</td>
</tr>
))}
{/* Section subtotal */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(SECTION_TOTAL_KEY[section.type])}
</td>
{months.map((month) => (
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
{cadFormatter(section.monthly[month])}
</td>
))}
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(section.total)}
</td>
</tr>
</Fragment>
))}
{/* Result before transfers — shown only when transfers exist */}
{hasTransfers && (
<tr className="border-t-2 border-[var(--border)] font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.resultBeforeTransfers")}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(beforeTransfers.monthly[month]) }}
>
{cadFormatter(beforeTransfers.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(beforeTransfers.total) }}
>
{cadFormatter(beforeTransfers.total)}
</td>
</tr>
)}
{/* Net result — bottom line (income expenses + transfers) */}
<tr
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
hasTransfers ? "border-b border-[var(--border)]" : "border-t-2 border-[var(--border)]"
}`}
>
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.resultNet")}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(net.monthly[month]) }}
>
{cadFormatter(net.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(net.total) }}
>
{cadFormatter(net.total)}
</td>
</tr>
{nonTransferSections.map(renderSection)}
{/* Operating result (revenues expenses), interleaved before the
transfers section only when transfers exist otherwise it equals
the net total below and would just be noise. */}
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
{transferSection && renderSection(transferSection)}
{/* Bottom line: result after netting transfers. */}
{renderResultRow("reports.compare.resultNet", net, true)}
</tbody>
</table>
</div>

View file

@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { visibleRows } from "../../utils/collapsibleRows";
import { computeOverTimeResults } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
} from "./overTimeTableModel";
type RowType = "income" | "expense" | "transfer";
/** Builds one id-keyed tree row; `monthly` is index-aligned to the months list. */
function mkRow(
categoryId: number,
categoryName: string,
monthly: number[],
category_type: RowType,
extra: Partial<OverTimeRow> = {},
): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type,
...extra,
};
}
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
return {
categories: [],
data: months.map((month) => ({ month })),
colors: {},
categoryIds: {},
types: {},
tree,
};
}
const MONTHS = ["2025-01", "2025-02"];
// A realistic parent-first depth-first tree (income → expense → transfer):
// Revenus (parent, id 1)
// Paie (leaf, id 11)
// Bonus (leaf, id 12)
// Dépenses (parent, id 2)
// Épicerie (leaf, id 21)
// Resto (leaf, id 22)
// Loyer (top-level leaf, id 23)
// Épargne (transfer leaf, id 3)
function buildTree(): OverTimeRow[] {
return [
mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }),
mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 2, depth: 1 }),
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 2, depth: 1 }),
mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
];
}
const acc = OVERTIME_COLLAPSE_ACCESSORS;
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */
const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
describe("overTimeTableModel — storage key", () => {
it("uses a trends-specific key, distinct from the comparable tables", () => {
expect(OVERTIME_EXPANDED_STORAGE_KEY).toBe("reports-trends-expanded");
expect(OVERTIME_EXPANDED_STORAGE_KEY).not.toBe("reports-compare-expanded");
});
});
describe("overTimeTableModel — collapse accessors", () => {
it("keys by category id and reads the snake_case hierarchy block", () => {
const parent = buildTree()[0];
const leaf = buildTree()[1];
expect(acc.keyOf(parent)).toBe("1");
expect(acc.depthOf(leaf)).toBe(1);
expect(acc.isParent(parent)).toBe(true);
expect(acc.isParent(leaf)).toBe(false);
});
});
describe("overTimeTableModel.groupOverTimeSections", () => {
it("splits the tree into ordered sections carrying the FULL hierarchy (parents + leaves)", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
// Income-first, transfer split out for interleaving.
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
expect(transferSection?.type).toBe("transfer");
// Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
const income = nonTransferSections.find((s) => s.type === "income")!;
expect(income.rows.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(income.rows[0].is_parent).toBe(true);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
expect(expense.rows.map((r) => r.categoryName)).toEqual([
"Dépenses",
"Épicerie",
"Resto",
"Loyer",
]);
expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
});
it("keeps subtotals as the reducer's leaf sums — a parent is never double-counted", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
// Paie 6000 + Bonus 500 = 6500 (the Revenus parent's own 6500 is NOT added again).
expect(income.total).toBe(6500);
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });
const expense = nonTransferSections.find((s) => s.type === "expense")!;
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded).
expect(expense.total).toBe(3300);
});
it("drops empty sections: no transfers → transferSection is null", () => {
const tree = buildTree().filter((r) => r.category_type !== "transfer");
const data = makeData(MONTHS, tree);
const analysis = computeOverTimeResults(data);
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
expect(transferSection).toBeNull();
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
});
});
describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
it("is collapsed by default: only depth-0 rows survive an empty expanded set", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
// Revenus stays (its own subtotal row); Paie/Bonus are folded away.
expect(visIncome.map((r) => r.categoryName)).toEqual(["Revenus"]);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
// Dépenses folds its children; the top-level leaf Loyer always stays.
expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]);
});
it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
expect(visible.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
});
it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
// Figures come from the raw tree (never the visible rows), so they hold in
// every collapse state.
expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income 3300 expense
expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
expect(income.total).toBe(6500);
// Folding vs expanding a group only changes how many rows are visible.
const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
expect(collapsed.length).toBeLessThan(expanded.length);
// The subtotal the table shows is identical in both states.
expect(income.total).toBe(6500);
});
});

View file

@ -0,0 +1,89 @@
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,
};
}