feat(reports): CategoryOverTimeTable hierarchique + collapse + resultat interleave (#265) #269
5 changed files with 456 additions and 107 deletions
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
188
src/components/reports/overTimeTableModel.test.ts
Normal file
188
src/components/reports/overTimeTableModel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
89
src/components/reports/overTimeTableModel.ts
Normal file
89
src/components/reports/overTimeTableModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue