feat(rapports) : repli/depliage a chaque niveau de la hierarchie (socle + 3 rapports) #292

Open
maximus wants to merge 2 commits from issue-288-collapse-socle into main
11 changed files with 434 additions and 250 deletions
Showing only changes of commit 48adb3db77 - Show all commits

View file

@ -2,6 +2,10 @@
## [Non publié] ## [Non publié]
### Modifié
- Rapports : les trois tableaux de rapport hiérarchiques (Comparaison réel-vs-réel, réel-vs-budget, et Tendances par catégorie) permettent désormais de **replier ou déplier chaque niveau de la hiérarchie de catégories**, plus seulement le premier. Chaque catégorie parente — y compris les intermédiaires — porte son propre chevron ; en déplier une révèle ses enfants directs (eux-mêmes repliés), pour descendre un niveau à la fois. Les tableaux s'ouvrent entièrement repliés (seules les catégories de premier niveau visibles), « Tout déplier » ouvre tous les niveaux d'un coup, et vos choix de repli sont désormais enregistrés **par profil** — dans la base de données du profil, donc détruits avec lui — au lieu du navigateur. Les sous-totaux et les lignes de résultat restent rigoureusement inchangés quel que soit ce que vous repliez (#288).
## [0.13.0] - 2026-07-12 ## [0.13.0] - 2026-07-12
### Ajouté ### Ajouté

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Changed
- Reports: the three hierarchical report tables (real-vs-real Compare, real-vs-budget Compare, and Trends by category) now let you **collapse or expand every level of the category hierarchy**, not just the top level. Every parent category — including the intermediate ones — carries its own chevron; expanding one reveals its direct children (themselves collapsed), so you drill down one level at a time. The tables open fully collapsed (only the top-level categories visible), "Expand all" opens every level at once, and your collapse choices are now saved **per profile** — in the profile's own database, so they are destroyed with the profile — instead of in the browser. Subtotals and result lines stay exactly the same whatever you fold away (#288).
## [0.13.0] - 2026-07-12 ## [0.13.0] - 2026-07-12
### Added ### Added

View file

@ -29,11 +29,11 @@ interface BudgetVsActualTableProps {
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
// Collapse groups keyed by category id; depth mirrors the render logic below // Collapse groups keyed by category id; a row is hidden when any ancestor
// (a missing depth is derived from parent_id) so hidden rows are exactly a // (walking parent_id) is collapsed, so every parent level is collapsible.
// group's indented descendants.
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = { const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
keyOf: (row) => String(row.category_id), keyOf: (row) => `p:${row.category_id}`,
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0), depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
isParent: (row) => row.is_parent, isParent: (row) => row.is_parent,
}; };
@ -116,11 +116,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0; const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1; const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row); const collapsed = isParent && groups.isCollapsed(row);
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr <tr
key={`${row.category_id}-${row.is_parent}-${depth}`} key={`${row.category_id}-${row.is_parent}-${depth}`}
aria-level={isParent ? depth + 1 : undefined}
className={`border-b border-[var(--border)]/50 ${ className={`border-b border-[var(--border)]/50 ${
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" : 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" : "" isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
@ -133,7 +134,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]` ? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]` : `${paddingClass} bg-[var(--card)]`
}`}> }`}>
{isTopParent ? ( {isParent ? (
<button <button
type="button" type="button"
onClick={() => groups.toggle(row)} onClick={() => groups.toggle(row)}
@ -255,7 +256,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
{hasGroups && ( {hasGroups && (
<button <button
type="button" type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))} onClick={() => (allExpanded ? groups.collapseAll(data) : groups.expandAll(data))}
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" 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 ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}

View file

@ -93,12 +93,13 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
const depth = row.depth; const depth = row.depth;
const isTopParent = isParent && depth === 0; const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1; const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row); const collapsed = isParent && groups.isCollapsed(row);
const paddingClass = const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr <tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`} key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
aria-level={isParent ? depth + 1 : undefined}
className={`border-b border-[var(--border)]/50 ${ className={`border-b border-[var(--border)]/50 ${
isTopParent isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
@ -116,7 +117,7 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
: `${paddingClass} bg-[var(--card)]` : `${paddingClass} bg-[var(--card)]`
}`} }`}
> >
{isTopParent ? ( {isParent ? (
<button <button
type="button" type="button"
onClick={() => groups.toggle(row)} onClick={() => groups.toggle(row)}
@ -211,7 +212,7 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
<button <button
type="button" type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data.tree))} onClick={() => (allExpanded ? groups.collapseAll(data.tree) : 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" 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 ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}

View file

@ -1,74 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CategoryBreakdownItem } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
interface CategoryTableProps {
data: CategoryBreakdownItem[];
hiddenCategories?: Set<string>;
}
export default function CategoryTable({ data, hiddenCategories }: CategoryTableProps) {
const { t } = useTranslation();
const visibleData = hiddenCategories?.size
? data.filter((d) => !hiddenCategories.has(d.category_name))
: data;
if (visibleData.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
);
}
const grandTotal = visibleData.reduce((sum, row) => sum + row.total, 0);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("budget.category")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("common.total")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
%
</th>
</tr>
</thead>
<tbody>
{visibleData.map((row) => (
<tr key={row.category_id ?? "uncategorized"} className="border-b border-[var(--border)]/50">
<td className="px-3 py-1.5">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</span>
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.total)}</td>
<td className="text-right px-3 py-1.5 text-[var(--muted-foreground)]">
{grandTotal !== 0 ? `${((row.total / grandTotal) * 100).toFixed(1)}%` : "—"}
</td>
</tr>
))}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td>
<td className="text-right px-3 py-3">{cadFormatter(grandTotal)}</td>
<td className="text-right px-3 py-3 text-[var(--muted-foreground)]">100%</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -59,10 +59,11 @@ function deltaColor(value: number, higherIsBetter: boolean): string {
const STORAGE_KEY = "compare-subtotals-position"; const STORAGE_KEY = "compare-subtotals-position";
// Collapse groups keyed by category id; depth/parent mirror the render logic // Collapse groups keyed by category id; a row is hidden when any ancestor
// below so the hidden rows are exactly a group's indented descendants. // (walking parent_id) is collapsed, so every parent level is collapsible.
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = { const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
keyOf: (row) => String(row.categoryId), keyOf: (row) => `p:${row.categoryId}`,
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
depthOf: (row) => row.depth ?? 0, depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent ?? false, isParent: (row) => row.is_parent ?? false,
}; };
@ -154,12 +155,13 @@ export default function ComparePeriodTable({
const depth = row.depth ?? 0; const depth = row.depth ?? 0;
const isTopParent = isParent && depth === 0; const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1; const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row); const collapsed = isParent && groups.isCollapsed(row);
const paddingClass = const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr <tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`} key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
aria-level={isParent ? depth + 1 : undefined}
className={`border-b border-[var(--border)]/50 ${ className={`border-b border-[var(--border)]/50 ${
isTopParent isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
@ -177,7 +179,7 @@ export default function ComparePeriodTable({
: `${paddingClass} bg-[var(--card)]` : `${paddingClass} bg-[var(--card)]`
}`} }`}
> >
{isTopParent ? ( {isParent ? (
<button <button
type="button" type="button"
onClick={() => groups.toggle(row)} onClick={() => groups.toggle(row)}
@ -349,7 +351,7 @@ export default function ComparePeriodTable({
{hasGroups && ( {hasGroups && (
<button <button
type="button" type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(rows))} onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
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" 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 ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}

View file

@ -45,23 +45,26 @@ function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
const MONTHS = ["2025-01", "2025-02"]; const MONTHS = ["2025-01", "2025-02"];
// A realistic parent-first depth-first tree (income → expense → transfer): // A realistic parent-first depth-first tree, three levels deep on the expense
// side (income → expense → transfer):
// Revenus (parent, id 1) // Revenus (parent, id 1)
// Paie (leaf, id 11) // Paie (leaf, id 11)
// Bonus (leaf, id 12) // Bonus (leaf, id 12)
// Dépenses (parent, id 2) // Dépenses (parent, id 2)
// Épicerie (leaf, id 21) // Alimentation (INTERMEDIATE parent, id 20)
// Resto (leaf, id 22) // Épicerie (leaf, id 21)
// Loyer (top-level leaf, id 23) // Resto (leaf, id 22)
// Épargne (transfer leaf, id 3) // Loyer (top-level leaf, id 23)
// Épargne (transfer leaf, id 3)
function buildTree(): OverTimeRow[] { function buildTree(): OverTimeRow[] {
return [ return [
mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }), mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }), mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
mkRow(12, "Bonus", [0, 500], "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(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 2, depth: 1 }), mkRow(20, "Alimentation", [500, 800], "expense", { is_parent: true, parent_id: 2, depth: 1 }),
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 2, depth: 1 }), mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 20, depth: 2 }),
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 20, depth: 2 }),
mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }), mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }), mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
]; ];
@ -70,6 +73,7 @@ function buildTree(): OverTimeRow[] {
const acc = OVERTIME_COLLAPSE_ACCESSORS; const acc = OVERTIME_COLLAPSE_ACCESSORS;
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */ /** 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)); const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
const names = (rows: OverTimeRow[]) => rows.map((r) => r.categoryName);
describe("overTimeTableModel — storage key", () => { describe("overTimeTableModel — storage key", () => {
it("uses a trends-specific key, distinct from the comparable tables", () => { it("uses a trends-specific key, distinct from the comparable tables", () => {
@ -79,13 +83,18 @@ describe("overTimeTableModel — storage key", () => {
}); });
describe("overTimeTableModel — collapse accessors", () => { describe("overTimeTableModel — collapse accessors", () => {
it("keys by category id and reads the snake_case hierarchy block", () => { it("keys parents/leaves injectively (p:<id>) and climbs parent_id", () => {
const parent = buildTree()[0]; const revenus = buildTree()[0]; // parent
const leaf = buildTree()[1]; const paie = buildTree()[1]; // leaf under Revenus
expect(acc.keyOf(parent)).toBe("1"); const alimentation = buildTree()[4]; // intermediate parent
expect(acc.depthOf(leaf)).toBe(1); expect(acc.keyOf(revenus)).toBe("p:1");
expect(acc.isParent(parent)).toBe(true); expect(acc.parentKeyOf(revenus)).toBeNull(); // root
expect(acc.isParent(leaf)).toBe(false); expect(acc.parentKeyOf(paie)).toBe("p:1"); // climbs to Revenus
expect(acc.parentKeyOf(alimentation)).toBe("p:2"); // climbs to Dépenses
expect(acc.depthOf(paie)).toBe(1);
expect(acc.isParent(revenus)).toBe(true);
expect(acc.isParent(paie)).toBe(false);
expect(acc.isParent(alimentation)).toBe(true);
}); });
}); });
@ -101,12 +110,14 @@ describe("overTimeTableModel.groupOverTimeSections", () => {
// Full hierarchy: the parent row is present (unlike the leaves-only reducer sections). // Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
const income = nonTransferSections.find((s) => s.type === "income")!; const income = nonTransferSections.find((s) => s.type === "income")!;
expect(income.rows.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]); expect(names(income.rows)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(income.rows[0].is_parent).toBe(true); expect(income.rows[0].is_parent).toBe(true);
// Expense carries the intermediate parent (Alimentation) too, parent-first.
const expense = nonTransferSections.find((s) => s.type === "expense")!; const expense = nonTransferSections.find((s) => s.type === "expense")!;
expect(expense.rows.map((r) => r.categoryName)).toEqual([ expect(names(expense.rows)).toEqual([
"Dépenses", "Dépenses",
"Alimentation",
"Épicerie", "Épicerie",
"Resto", "Resto",
"Loyer", "Loyer",
@ -114,7 +125,7 @@ describe("overTimeTableModel.groupOverTimeSections", () => {
expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]); expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
}); });
it("keeps subtotals as the reducer's leaf sums — a parent is never double-counted", () => { it("keeps subtotals as the reducer's leaf sums — parents (incl. intermediate) are never double-counted", () => {
const data = makeData(MONTHS, buildTree()); const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data); const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree); const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
@ -125,7 +136,8 @@ describe("overTimeTableModel.groupOverTimeSections", () => {
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 }); expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });
const expense = nonTransferSections.find((s) => s.type === "expense")!; const expense = nonTransferSections.find((s) => s.type === "expense")!;
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded). // Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses AND Alimentation
// parents excluded, even though Alimentation carries a [500,800] series).
expect(expense.total).toBe(3300); expect(expense.total).toBe(3300);
}); });
@ -148,12 +160,13 @@ describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
const income = nonTransferSections.find((s) => s.type === "income")!; const income = nonTransferSections.find((s) => s.type === "income")!;
const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set())); const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
// Revenus stays (its own subtotal row); Paie/Bonus are folded away. // Revenus stays (its own subtotal row); Paie/Bonus are folded away.
expect(visIncome.map((r) => r.categoryName)).toEqual(["Revenus"]); expect(names(visIncome)).toEqual(["Revenus"]);
const expense = nonTransferSections.find((s) => s.type === "expense")!; const expense = nonTransferSections.find((s) => s.type === "expense")!;
const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set())); const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
// Dépenses folds its children; the top-level leaf Loyer always stays. // Dépenses folds its whole subtree (Alimentation + its leaves); the top-level
expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]); // leaf Loyer always stays.
expect(names(visExpense)).toEqual(["Dépenses", "Loyer"]);
}); });
it("reveals Paie (revenue) under Revenus once its group is expanded", () => { it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
@ -161,28 +174,55 @@ describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree); const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!; const income = nonTransferSections.find((s) => s.type === "income")!;
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"]))); const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["p:1"])));
expect(visible.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]); expect(names(visible)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(visible.some((r) => r.categoryName === "Paie")).toBe(true); expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
}); });
it("cascades across three levels: a root reveals only its direct child parent, not the leaves", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
// Expand Dépenses only: Alimentation (direct child) shows, Loyer stays, but
// Épicerie/Resto (grandchildren under the still-collapsed Alimentation) do NOT.
const rootOnly = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2"])));
expect(names(rootOnly)).toEqual(["Dépenses", "Alimentation", "Loyer"]);
// Expand Dépenses AND Alimentation: the leaves finally appear.
const bothOpen = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2", "p:20"])));
expect(names(bothOpen)).toEqual(["Dépenses", "Alimentation", "Épicerie", "Resto", "Loyer"]);
// Expand the intermediate but NOT the root: a collapsed ancestor wins, nothing
// under Dépenses is revealed.
const intermediateOnly = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:20"])));
expect(names(intermediateOnly)).toEqual(["Dépenses", "Loyer"]);
});
it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => { it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
const data = makeData(MONTHS, buildTree()); const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data); const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree); const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!; const income = nonTransferSections.find((s) => s.type === "income")!;
const expense = nonTransferSections.find((s) => s.type === "expense")!;
// Figures come from the raw tree (never the visible rows), so they hold in // Figures come from the raw tree (never the visible rows), so they hold in
// every collapse state. // every collapse state.
expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income 3300 expense expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income 3300 expense
expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
expect(income.total).toBe(6500); expect(income.total).toBe(6500);
expect(expense.total).toBe(3300);
// Folding vs expanding a group only changes how many rows are visible. // Folding vs expanding a group only changes how many rows are visible.
const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set())); const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"]))); const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["p:1"])));
expect(collapsed.length).toBeLessThan(expanded.length); expect(collapsed.length).toBeLessThan(expanded.length);
// The subtotal the table shows is identical in both states.
expect(income.total).toBe(6500); // Expanding the intermediate expense level does not move the section subtotal.
const expenseAllOpen = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2", "p:20"])));
expect(expenseAllOpen.length).toBeGreaterThan(
visibleRows(expense.rows, acc, isCollapsed(new Set())).length,
);
expect(expense.total).toBe(3300);
}); });
}); });

View file

@ -10,13 +10,14 @@ import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
*/ */
/** /**
* Collapse groups keyed by category id; depth/parent mirror the tree the render * Collapse groups keyed by category id; a row is hidden when any ancestor
* indents by, so a collapsed group hides exactly its indented descendants. The * (walking parent_id) is collapsed, so every parent level is collapsible. The
* `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these * `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these
* accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged. * accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged.
*/ */
export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = { export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = {
keyOf: (row) => String(row.categoryId), keyOf: (row) => `p:${row.categoryId}`,
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
depthOf: (row) => row.depth, depthOf: (row) => row.depth,
isParent: (row) => row.is_parent, isParent: (row) => row.is_parent,
}; };

View file

@ -1,93 +1,133 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
type CollapseAccessors, type CollapseAccessors,
collapsibleKeys, collapsibleKeys,
isCollapsedFor,
parseStoredExpanded, parseStoredExpanded,
serializeExpanded, serializeExpanded,
visibleRows, visibleRows,
} from "../utils/collapsibleRows"; } from "../utils/collapsibleRows";
import { getPreference, setPreference } from "../services/userPreferenceService";
export interface CollapsibleGroups<T> { export interface CollapsibleGroups<T> {
/** Filters a section's rows down to the ones visible under the current state. */ /** Filters a section's rows down to the ones visible under the current state. */
visible: (rows: T[]) => T[]; visible: (rows: T[]) => T[];
/** True when this top-level parent row is collapsed (its children are hidden). */ /** True when this parent row is collapsed (its subtree is hidden). */
isCollapsed: (row: T) => boolean; isCollapsed: (row: T) => boolean;
/** Flip one group between collapsed and expanded, then persist. */ /** Flip one group between collapsed and expanded, then persist. */
toggle: (row: T) => void; toggle: (row: T) => void;
/** Expand every collapsible group found in `rows`, then persist. */ /** Expand every collapsible group found in `rows`, then persist. */
expandAll: (rows: T[]) => void; expandAll: (rows: T[]) => void;
/** Collapse every group (back to the default), then persist. */ /** Collapse every collapsible group found in `rows`, then persist. */
collapseAll: () => void; collapseAll: (rows: T[]) => void;
/** True when `rows` has at least one group and all of them are expanded. */ /** True when `rows` has at least one group and all of them are expanded. */
allExpanded: (rows: T[]) => boolean; allExpanded: (rows: T[]) => boolean;
/** Number of collapsible top-level groups in `rows`. */ /** Number of collapsible groups (parents, any depth) in `rows`. */
groupCount: (rows: T[]) => number; groupCount: (rows: T[]) => number;
} }
/** /**
* Per-report collapse/expand state for the hierarchical comparable tables * Per-report collapse/expand state for the hierarchical tables (issue #254/#265),
* (issue #254). Groups are collapsed by default (issue #260): the persisted * generalised to every hierarchy level (issue #288).
* value is the set of *expanded* group keys, so a first-ever visit (empty set)
* shows every group collapsed with only its subtotal, while any group the user
* expands is remembered.
* *
* Persistence uses localStorage, the same synchronous store the sibling * The persisted value is the set of keys whose state DIFFERS from the default
* "subtotals on top/bottom" toggle already uses in these tables. State is kept * (see `isCollapsedFor`), so `defaultExpanded: false` + an empty set = everything
* per report via a distinct `storageKey`. * collapsed the reports' established behaviour while `defaultExpanded: true`
* + an empty set = everything expanded.
*
* Persistence lives in `user_preferences`, the profile's own SQLite table, so the
* state is scoped per profile for free and is destroyed with the profile (no
* localStorage residue surviving `deleteProfile` a privacy-first requirement).
* Reads/writes are async; hydration runs in an effect. There is no visible flash
* because the default state IS what renders first, and hydration can only reveal
* what the user had opened. `storageKey: null` disables persistence entirely
* (pure in-memory state, for the category trees).
*
* The hook stays context-free it never calls `useProfile()` because the
* `user_preferences` key is already per-profile (it lives in the profile's DB).
*/ */
export function useCollapsibleGroups<T>( export function useCollapsibleGroups<T>(
storageKey: string, storageKey: string | null,
acc: CollapseAccessors<T>, acc: CollapseAccessors<T>,
options?: { defaultExpanded?: boolean },
): CollapsibleGroups<T> { ): CollapsibleGroups<T> {
const [expanded, setExpanded] = useState<Set<string>>(() => const defaultExpanded = options?.defaultExpanded ?? false;
parseStoredExpanded(
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null, // Keys whose state differs from the default. Initial = default (empty), then an
), // effect hydrates from user_preferences when a storageKey is set.
); const [flipped, setFlipped] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (storageKey === null) {
// No persistence: reset to the default state on (re)mount / key change.
setFlipped(new Set());
return;
}
let cancelled = false;
void getPreference(storageKey)
.then((raw) => {
if (!cancelled) setFlipped(parseStoredExpanded(raw));
})
.catch(() => {
// Best-effort: a read failure just keeps the default state.
});
return () => {
cancelled = true;
};
}, [storageKey]);
const persist = useCallback( const persist = useCallback(
(next: Set<string>) => { (next: Set<string>) => {
try { setFlipped(next);
localStorage.setItem(storageKey, serializeExpanded(next)); if (storageKey !== null) {
} catch { void setPreference(storageKey, serializeExpanded(next)).catch(() => {
// Best-effort: a write failure just means the next session falls back // Best-effort: a write failure just means the next session falls back
// to the collapsed default. // to the default state.
});
} }
setExpanded(next);
}, },
[storageKey], [storageKey],
); );
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]); const isCollapsed = useCallback(
(row: T) => isCollapsedFor(flipped, acc.keyOf(row), defaultExpanded),
[flipped, acc, defaultExpanded],
);
const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]); const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]);
const toggle = useCallback( const toggle = useCallback(
(row: T) => { (row: T) => {
const key = acc.keyOf(row); const key = acc.keyOf(row);
const next = new Set(expanded); const next = new Set(flipped);
if (next.has(key)) next.delete(key); if (next.has(key)) next.delete(key);
else next.add(key); else next.add(key);
persist(next); persist(next);
}, },
[expanded, acc, persist], [flipped, acc, persist],
); );
// In `defaultExpanded` polarity the flipped set holds the *collapsed* keys, so
// expanding everything means clearing it; collapsing everything means filling
// it. The polarity is inverted when the default is collapsed.
const expandAll = useCallback( const expandAll = useCallback(
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))), (rows: T[]) => persist(defaultExpanded ? new Set() : new Set(collapsibleKeys(rows, acc))),
[acc, persist], [acc, persist, defaultExpanded],
); );
const collapseAll = useCallback(() => persist(new Set()), [persist]); const collapseAll = useCallback(
(rows: T[]) => persist(defaultExpanded ? new Set(collapsibleKeys(rows, acc)) : new Set()),
[acc, persist, defaultExpanded],
);
const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]); const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]);
const allExpanded = useCallback( const allExpanded = useCallback(
(rows: T[]) => { (rows: T[]) => {
const keys = collapsibleKeys(rows, acc); const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((k) => expanded.has(k)); return keys.length > 0 && keys.every((k) => !isCollapsedFor(flipped, k, defaultExpanded));
}, },
[expanded, acc], [flipped, acc, defaultExpanded],
); );
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount }; return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };

View file

@ -1,102 +1,234 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
type CollapseAccessors, type CollapseAccessors,
MAX_TREE_DEPTH,
collapsibleKeys, collapsibleKeys,
isCollapsedFor,
parseStoredExpanded, parseStoredExpanded,
serializeExpanded, serializeExpanded,
visibleRows, visibleRows,
} from "./collapsibleRows"; } from "./collapsibleRows";
import { reorderRows } from "./reorderRows";
/** Tiny hierarchy row for the tests: key, depth, is-parent. */ /** Tiny hierarchy row: id, parent id, is-parent flag, indentation depth. */
interface Row { interface Row {
k: string; id: number;
d: number; parent: number | null;
p: boolean; is_parent: boolean;
depth: number;
} }
const acc: CollapseAccessors<Row> = { const acc: CollapseAccessors<Row> = {
keyOf: (r) => r.k, keyOf: (r) => `p:${r.id}`,
depthOf: (r) => r.d, parentKeyOf: (r) => (r.parent === null ? null : `p:${r.parent}`),
isParent: (r) => r.p, isParent: (r) => r.is_parent,
depthOf: (r) => r.depth,
}; };
const row = (k: string, d: number, p: boolean): Row => ({ k, d, p }); const r = (id: number, parent: number | null, is_parent: boolean, depth: number): Row => ({
id,
parent,
is_parent,
depth,
});
// A two-group tree with one nested sub-group: // A three-level tree (income → expense → transfer shape is irrelevant here; the
// Housing (parent, d0) // point is the depth):
// Rent (leaf, d1) // Housing (parent, id 1)
// Utilities (parent, d1) <- intermediate parent // Rent (leaf, id 11)
// Power (leaf, d2) // Utilities (INTERMEDIATE parent, id 12)
// Salary (parent, d0) // Power (leaf, id 121)
// Paycheck (leaf, d1) // Water (leaf, id 122)
// Misc (leaf, d0) <- top-level leaf, not a group // Salary (parent, id 2)
const tree: Row[] = [ // Paycheck (leaf, id 21)
row("housing", 0, true), // Misc (top-level leaf, id 3)
row("rent", 1, false), const dfsTree: Row[] = [
row("utilities", 1, true), r(1, null, true, 0), // Housing
row("power", 2, false), r(11, 1, false, 1), // Rent
row("salary", 0, true), r(12, 1, true, 1), // Utilities (intermediate)
row("paycheck", 1, false), r(121, 12, false, 2), // Power
row("misc", 0, false), r(122, 12, false, 2), // Water
r(2, null, true, 0), // Salary
r(21, 2, false, 1), // Paycheck
r(3, null, false, 0), // Misc
]; ];
// Default = collapsed: a group is collapsed unless its key is in the expanded set. // The EXACT same tree, emitted level-order (BFS) — the order the budget grid
const isCollapsed = (expanded: Set<string>) => (r: Row) => !expanded.has(r.k); // produces. The ancestor-walk must mask identically to the DFS order.
const bfsTree: Row[] = [
r(1, null, true, 0), // Housing
r(2, null, true, 0), // Salary
r(3, null, false, 0), // Misc
r(11, 1, false, 1), // Rent
r(12, 1, true, 1), // Utilities
r(21, 2, false, 1), // Paycheck
r(121, 12, false, 2), // Power
r(122, 12, false, 2), // Water
];
describe("collapsibleRows.visibleRows", () => { /** Report polarity: a group is collapsed unless its key is in the expanded set. */
it("collapses every group by default (empty expanded set)", () => { const isCollapsed = (expanded: Set<string>) => (row: Row) => !expanded.has(acc.keyOf(row));
const visible = visibleRows(tree, acc, isCollapsed(new Set())); const ids = (rows: Row[]) => rows.map((row) => row.id);
// Only depth-0 rows survive: the two group headers + the top-level leaf. const idSet = (rows: Row[]) => new Set(ids(rows));
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "misc"]);
describe("collapsibleRows.visibleRows — ancestor-walk visibility", () => {
it("collapses every level by default (empty set) ⇒ only roots survive", () => {
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set()));
// Housing + Salary (root parents) + Misc (root leaf); everything deeper folds.
expect(ids(visible)).toEqual([1, 2, 3]);
}); });
it("reveals a group's full subtree (incl. nested sub-groups) when expanded", () => { it("expanding a root reveals ONLY its direct children, not its grandchildren", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing"]))); const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1"])));
expect(visible.map((r) => r.k)).toEqual([ // Rent + Utilities appear; Power/Water (grandchildren) stay folded because the
"housing", // intermediate Utilities is still collapsed.
"rent", expect(ids(visible)).toEqual([1, 11, 12, 2, 3]);
"utilities", expect(visible.some((row) => row.id === 121)).toBe(false);
"power", expect(visible.some((row) => row.id === 122)).toBe(false);
"salary",
"misc",
]);
}); });
it("keeps groups independent — expanding one leaves the others collapsed", () => { it("expanding a root AND its intermediate reveals the leaves", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["salary"]))); const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1", "p:12"])));
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "paycheck", "misc"]); expect(ids(visible)).toEqual([1, 11, 12, 121, 122, 2, 3]);
}); });
it("shows everything when all groups are expanded", () => { it("a collapsed ancestor always wins: expanding an intermediate whose root is collapsed reveals nothing", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"]))); const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:12"])));
expect(visible).toHaveLength(tree.length); // Housing is collapsed, so Utilities (and thus Power/Water) stay hidden even
// though Utilities itself is expanded.
expect(ids(visible)).toEqual([1, 2, 3]);
}); });
it("always keeps a top-level leaf that has no group", () => { it("keeps groups independent — expanding Salary leaves Housing folded", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set())); const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:2"])));
expect(visible.some((r) => r.k === "misc")).toBe(true); expect(ids(visible)).toEqual([1, 2, 21, 3]);
});
it("shows every row once all parents are expanded", () => {
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1", "p:12", "p:2"])));
expect(visible).toHaveLength(dfsTree.length);
});
it("BFS (level-order) rows mask IDENTICALLY to DFS rows — the test that would have killed v1", () => {
const states: Set<string>[] = [
new Set(),
new Set(["p:1"]),
new Set(["p:1", "p:12"]),
new Set(["p:12"]),
new Set(["p:2"]),
new Set(["p:1", "p:12", "p:2"]),
];
for (const expanded of states) {
const dfs = idSet(visibleRows(dfsTree, acc, isCollapsed(expanded)));
const bfs = idSet(visibleRows(bfsTree, acc, isCollapsed(expanded)));
expect(bfs).toEqual(dfs);
}
});
it("masking is unchanged when subtotals are moved to the bottom (reorderRows)", () => {
const expanded = new Set(["p:1", "p:12"]);
const normal = idSet(visibleRows(dfsTree, acc, isCollapsed(expanded)));
// Order-independent: run reorder FIRST (subtotals below their children), then
// filter. v1's depth-cursor algorithm broke exactly here.
const bottomFirst = reorderRows(dfsTree, false);
const afterReorder = idSet(visibleRows(bottomFirst, acc, isCollapsed(expanded)));
expect(afterReorder).toEqual(normal);
});
it("always keeps a top-level leaf that has no parent", () => {
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set()));
expect(visible.some((row) => row.id === 3)).toBe(true);
});
});
describe('collapsibleRows.visibleRows — "(direct)" leaf', () => {
// A parent that also holds direct transactions: its "(direct)" leaf shares the
// parent's category id but is a leaf, and points back at the parent subtotal.
const withDirect: Row[] = [
r(1, null, true, 0), // Housing subtotal -> key p:1
r(1, 1, false, 1), // Housing (direct) -> key p:1, parentKey p:1
r(11, 1, false, 1), // Rent
];
it("hides the (direct) leaf when its parent is collapsed", () => {
const visible = visibleRows(withDirect, acc, isCollapsed(new Set()));
// Only the Housing subtotal (a root) survives.
expect(visible).toHaveLength(1);
expect(visible[0].is_parent).toBe(true);
});
it("shows the (direct) leaf when its parent is expanded", () => {
const visible = visibleRows(withDirect, acc, isCollapsed(new Set(["p:1"])));
expect(visible).toHaveLength(3);
});
it("never treats the (direct) leaf as a collapsible group", () => {
// Both the subtotal and the (direct) leaf carry key p:1, but only the parent
// subtotal is collapsible — the leaf is not a parent.
expect(collapsibleKeys(withDirect, acc)).toEqual(["p:1"]);
});
});
describe("collapsibleRows.visibleRows — corrupt / cross-section chains", () => {
it("guards a cyclic parent_id with MAX_TREE_DEPTH (no infinite loop)", () => {
// A <-> B cycle, with a leaf hanging under A. Neither A nor B is collapsed, so
// the walk keeps climbing until the hop guard trips.
const cyclic: Row[] = [
r(100, 101, true, 0), // A, parent B
r(101, 100, true, 1), // B, parent A
r(1001, 100, false, 2), // leaf under A
];
const visible = visibleRows(cyclic, acc, isCollapsed(new Set(["p:100", "p:101"])));
// Terminates and keeps the leaf visible (no collapsed ancestor was found).
expect(visible.some((row) => row.id === 1001)).toBe(true);
// Sanity: the guard is a finite, positive cap.
expect(MAX_TREE_DEPTH).toBeGreaterThan(3);
});
it("keeps a row whose ancestor is absent from the section (child of another type)", () => {
// Orphan points at id 999, which is not in these rows.
const orphaned: Row[] = [r(55, 999, false, 1)];
const visible = visibleRows(orphaned, acc, isCollapsed(new Set()));
// Missing ancestor ⇒ visible (better an orphan shown than a row hidden by a
// parent that lives in another section).
expect(ids(visible)).toEqual([55]);
}); });
}); });
describe("collapsibleRows.collapsibleKeys", () => { describe("collapsibleRows.collapsibleKeys", () => {
it("lists only top-level parent rows, in order", () => { it("lists EVERY parent, intermediates included, in encounter order", () => {
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]); // Housing (p:1), Utilities (p:12, intermediate), Salary (p:2) — NOT Misc/leaves.
expect(collapsibleKeys(dfsTree, acc)).toEqual(["p:1", "p:12", "p:2"]);
}); });
it("returns [] when there is no hierarchy", () => { it("returns [] when there is no hierarchy", () => {
const flat = [row("a", 0, false), row("b", 0, false)]; const flat = [r(1, null, false, 0), r(2, null, false, 0)];
expect(collapsibleKeys(flat, acc)).toEqual([]); expect(collapsibleKeys(flat, acc)).toEqual([]);
}); });
}); });
describe("collapsibleRows.isCollapsedFor — polarity", () => {
it("defaultExpanded=false: empty set ⇒ collapsed, a flipped key ⇒ expanded", () => {
expect(isCollapsedFor(new Set(), "p:1", false)).toBe(true);
expect(isCollapsedFor(new Set(["p:1"]), "p:1", false)).toBe(false);
expect(isCollapsedFor(new Set(["p:2"]), "p:1", false)).toBe(true);
});
it("defaultExpanded=true: empty set ⇒ expanded, a flipped key ⇒ collapsed", () => {
expect(isCollapsedFor(new Set(), "p:1", true)).toBe(false);
expect(isCollapsedFor(new Set(["p:1"]), "p:1", true)).toBe(true);
expect(isCollapsedFor(new Set(["p:2"]), "p:1", true)).toBe(false);
});
});
describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => { describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
it("treats absent storage as the collapsed default (empty set)", () => { it("treats absent storage as the default state (empty set)", () => {
expect(parseStoredExpanded(null).size).toBe(0); expect(parseStoredExpanded(null).size).toBe(0);
expect(parseStoredExpanded("").size).toBe(0); expect(parseStoredExpanded("").size).toBe(0);
}); });
it("parses a JSON string array back into a set", () => { it("parses a JSON string array back into a set", () => {
expect([...parseStoredExpanded('["housing","salary"]')].sort()).toEqual(["housing", "salary"]); expect([...parseStoredExpanded('["p:1","p:2"]')].sort()).toEqual(["p:1", "p:2"]);
}); });
it("falls back to empty on corrupt or non-array JSON", () => { it("falls back to empty on corrupt or non-array JSON", () => {
@ -110,7 +242,7 @@ describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
}); });
it("round-trips through serialize", () => { it("round-trips through serialize", () => {
const set = new Set(["a", "b"]); const set = new Set(["p:1", "p:12"]);
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]); expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["p:1", "p:12"]);
}); });
}); });

View file

@ -1,71 +1,104 @@
/** /**
* Collapse/expand of top-level category groups in the hierarchical comparable * Collapse/expand of category groups in the hierarchical reports (real-vs-real
* reports (real-vs-real Compare + Budget-vs-Actual) issue #254. * Compare, Budget-vs-Actual, trends over time) issue #254/#265, generalised to
* every hierarchy level in issue #288.
* *
* Pure helpers only; the React state + persistence glue lives in the * Pure helpers only; the React state + persistence glue lives in the
* `useCollapsibleGroups` hook. * `useCollapsibleGroups` hook.
* *
* A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps * Visibility is decided by an ANCESTOR WALK, not by row adjacency: a row is
* its own subtotal row visible but hides its entire subtree (every following * hidden iff *any* of its ancestors is collapsed. We climb `parentKeyOf` from a
* depth 1 row until the next depth-0 row). Rows must arrive in depth-first * row until we reach a root (null), an ancestor outside this section ( visible),
* order the order the compare/budget services already emit them in, and the * or a collapsed ancestor ( hidden). This is independent of the order rows
* same order `reorderRows` relies on. * arrive in the fix for the v1 adjacency algorithm, which assumed a
* depth-first order the budget grid (level-ordered) does not emit.
* *
* The accessors are passed in rather than read off fixed field names so each * The accessors are supplied by each consumer so every table can key + climb the
* table can supply the *exact* depth expression it renders with (CategoryDelta * exact hierarchy it renders. Keys are prefixed `p:` so they are injective: a
* uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from * parent subtotal row and its own "(direct)" leaf share a category id but the
* `parent_id`). Keeping the collapse depth and the indentation depth identical * leaf is never a parent, so only the subtotal is indexed the leaf simply
* guarantees the hidden rows are exactly the indented descendants. * carries the parent's key via `parentKeyOf` and is hidden with it.
*/ */
/**
* Cycle guard for the ancestor walk. The standard taxonomy is three levels deep;
* this generous finite cap only exists so a corrupt cyclic `parent_id` (DB
* corruption) can never loop forever. Any real chain terminates in a few hops.
*/
export const MAX_TREE_DEPTH = 64;
/** How the collapse logic reads hierarchy position + identity off a row. */ /** How the collapse logic reads hierarchy position + identity off a row. */
export interface CollapseAccessors<T> { export interface CollapseAccessors<T> {
/** Stable per-group key (a category id, stringified). */ /** Stable per-parent key, injective across the section: `p:<categoryId>`. */
keyOf: (row: T) => string; keyOf: (row: T) => string;
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */ /** Key of this row's parent (`p:<parent_id>`), or null at a root. */
depthOf: (row: T) => number; parentKeyOf: (row: T) => string | null;
/** True when the row is a group header / subtotal (has children). */ /** True when the row is a group header / subtotal (has children). */
isParent: (row: T) => boolean; isParent: (row: T) => boolean;
/** Indentation depth; 0 = top-level. Drives indentation + aria-level ONLY. */
depthOf: (row: T) => number;
} }
/** /**
* Keeps every row that is currently visible given which top-level groups are * Keeps every row that is currently visible given which groups are collapsed. A
* collapsed. `isCollapsed` is consulted only for top-level parent rows; a * row is dropped iff any ancestor (walking `parentKeyOf`) is collapsed; a missing
* collapsed one keeps its own (subtotal) row and drops its whole subtree. * ancestor (parent lives in another section) leaves the row visible.
*/ */
export function visibleRows<T>( export function visibleRows<T>(
rows: T[], rows: T[],
acc: CollapseAccessors<T>, acc: CollapseAccessors<T>,
isCollapsed: (row: T) => boolean, isCollapsed: (row: T) => boolean,
): T[] { ): T[] {
const out: T[] = []; // Index ONLY parents: a "(direct)" leaf shares its parent's category id but is
// While true we are inside a collapsed top-level parent's subtree and drop // never a parent itself, so it never clobbers the subtotal it points at.
// every deeper row until the next depth-0 row re-opens the flow. const parents = new Map<string, T>();
let hidingSubtree = false; for (const r of rows) if (acc.isParent(r)) parents.set(acc.keyOf(r), r);
for (const row of rows) {
if (acc.depthOf(row) === 0) { const hiddenByAncestor = (row: T): boolean => {
out.push(row); let key = acc.parentKeyOf(row);
hidingSubtree = acc.isParent(row) && isCollapsed(row); let hops = 0;
} else if (!hidingSubtree) { while (key !== null && hops++ < MAX_TREE_DEPTH) {
out.push(row); const parent = parents.get(key);
if (parent === undefined) return false; // ancestor outside this section → visible
if (isCollapsed(parent)) return true;
key = acc.parentKeyOf(parent);
} }
} return false;
return out; };
return rows.filter((r) => !hiddenByAncestor(r));
} }
/** Keys of every collapsible group (top-level parent rows), in encounter order. */ /** Keys of every collapsible group (all parent rows, any depth), in encounter order. */
export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] { export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] {
const keys: string[] = []; const keys: string[] = [];
for (const row of rows) { for (const row of rows) {
if (acc.depthOf(row) === 0 && acc.isParent(row)) keys.push(acc.keyOf(row)); if (acc.isParent(row)) keys.push(acc.keyOf(row));
} }
return keys; return keys;
} }
/** /**
* Parses the persisted value into the set of *expanded* group keys. The report * Polarity of the collapse state. The persisted Set holds the keys whose state
* default is "everything collapsed" (issue #254/#260), so an absent or corrupt * DIFFERS from the default, which lets one hook serve both polarities without a
* value yields an empty set i.e. all groups collapsed on a first-ever visit. * seed:
* - `defaultExpanded: false` (reports, budget) Set = the *expanded* keys, so
* an empty Set means everything collapsed (the reports' current behaviour).
* - `defaultExpanded: true` (category trees) Set = the *collapsed* keys, so an
* empty Set means everything expanded.
*/
export function isCollapsedFor(
flipped: Set<string>,
key: string,
defaultExpanded: boolean,
): boolean {
const isFlipped = flipped.has(key);
return defaultExpanded ? isFlipped : !isFlipped;
}
/**
* Parses the persisted value into the flipped-key set (see `isCollapsedFor`). An
* absent or corrupt value yields an empty set i.e. the default collapse state.
*/ */
export function parseStoredExpanded(raw: string | null): Set<string> { export function parseStoredExpanded(raw: string | null): Set<string> {
if (!raw) return new Set(); if (!raw) return new Set();
@ -75,12 +108,12 @@ export function parseStoredExpanded(raw: string | null): Set<string> {
return new Set(parsed.filter((k): k is string => typeof k === "string")); return new Set(parsed.filter((k): k is string => typeof k === "string"));
} }
} catch { } catch {
// Corrupt value: fall back to the collapsed default. // Corrupt value: fall back to the default state.
} }
return new Set(); return new Set();
} }
/** Serialises the set of expanded group keys for persistence. */ /** Serialises the flipped-key set for persistence. */
export function serializeExpanded(expanded: Set<string>): string { export function serializeExpanded(flipped: Set<string>): string {
return JSON.stringify([...expanded]); return JSON.stringify([...flipped]);
} }