feat(reports): collapse category hierarchy at every level (socle + 3 reports)
Generalize the report category collapse from level-1-only to every hierarchy level, on the three hierarchical report tables (real-vs-real Compare, real-vs-budget Compare, Trends by category). Visibility is now decided by an ancestor walk, not by row adjacency, so it is independent of row order (the level-ordered budget grid emits a non-DFS order). - collapsibleRows: rewrite visibleRows as an ancestor walk (a row is hidden iff any ancestor is collapsed); add parentKeyOf + injective `p:` keys; collapsibleKeys returns all parents (any depth); extract the pure, tested isCollapsedFor polarity helper; MAX_TREE_DEPTH cycle guard. - useCollapsibleGroups: persist in user_preferences (per-profile, destroyed with the profile) instead of localStorage; storageKey nullable (no persistence); options.defaultExpanded; async hydration (no flash); collapseAll(rows). - 3 tables: fix BOTH gates (collapsed flag + button) isTopParent -> isParent, add parentKeyOf accessors, aria-level on parent rows. - Delete dead CategoryTable.tsx (0 imports). - Tests: rewrite collapsibleRows.test.ts (BFS==DFS masking, cycle guard, cross-section ancestor, "(direct)" leaf, polarity); extend overTimeTableModel fixture to 3 levels with cascade assertions. Collapse stays purely visual: subtotals and result lines are computed from raw rows, never from visible rows. Resolves #288 Generated autonomously by /autopilot run of 2026-07-15 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
524fe162ea
commit
48adb3db77
11 changed files with 434 additions and 250 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [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
|
||||
|
||||
### Ajouté
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ interface BudgetVsActualTableProps {
|
|||
|
||||
const STORAGE_KEY = "subtotals-position";
|
||||
|
||||
// Collapse groups keyed by category id; depth mirrors the render logic below
|
||||
// (a missing depth is derived from parent_id) so hidden rows are exactly a
|
||||
// group's indented descendants.
|
||||
// Collapse groups keyed by category id; a row is hidden when any ancestor
|
||||
// (walking parent_id) is collapsed, so every parent level is collapsible.
|
||||
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),
|
||||
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 isTopParent = isParent && depth === 0;
|
||||
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";
|
||||
return (
|
||||
<tr
|
||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||
aria-level={isParent ? depth + 1 : undefined}
|
||||
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" : ""
|
||||
|
|
@ -133,7 +134,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||
: `${paddingClass} bg-[var(--card)]`
|
||||
}`}>
|
||||
{isTopParent ? (
|
||||
{isParent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => groups.toggle(row)}
|
||||
|
|
@ -255,7 +256,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
{hasGroups && (
|
||||
<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"
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
|
|
|
|||
|
|
@ -93,12 +93,13 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
|||
const depth = row.depth;
|
||||
const isTopParent = isParent && depth === 0;
|
||||
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";
|
||||
return (
|
||||
<tr
|
||||
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
||||
aria-level={isParent ? depth + 1 : undefined}
|
||||
className={`border-b border-[var(--border)]/50 ${
|
||||
isTopParent
|
||||
? "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)]`
|
||||
}`}
|
||||
>
|
||||
{isTopParent ? (
|
||||
{isParent ? (
|
||||
<button
|
||||
type="button"
|
||||
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)]">
|
||||
<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"
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,10 +59,11 @@ function deltaColor(value: number, higherIsBetter: boolean): string {
|
|||
|
||||
const STORAGE_KEY = "compare-subtotals-position";
|
||||
|
||||
// Collapse groups keyed by category id; depth/parent mirror the render logic
|
||||
// below so the hidden rows are exactly a group's indented descendants.
|
||||
// Collapse groups keyed by category id; a row is hidden when any ancestor
|
||||
// (walking parent_id) is collapsed, so every parent level is collapsible.
|
||||
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,
|
||||
isParent: (row) => row.is_parent ?? false,
|
||||
};
|
||||
|
|
@ -154,12 +155,13 @@ export default function ComparePeriodTable({
|
|||
const depth = row.depth ?? 0;
|
||||
const isTopParent = isParent && depth === 0;
|
||||
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";
|
||||
return (
|
||||
<tr
|
||||
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
||||
aria-level={isParent ? depth + 1 : undefined}
|
||||
className={`border-b border-[var(--border)]/50 ${
|
||||
isTopParent
|
||||
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
|
||||
|
|
@ -177,7 +179,7 @@ export default function ComparePeriodTable({
|
|||
: `${paddingClass} bg-[var(--card)]`
|
||||
}`}
|
||||
>
|
||||
{isTopParent ? (
|
||||
{isParent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => groups.toggle(row)}
|
||||
|
|
@ -349,7 +351,7 @@ export default function ComparePeriodTable({
|
|||
{hasGroups && (
|
||||
<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"
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
|
|
|
|||
|
|
@ -45,23 +45,26 @@ function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
|
|||
|
||||
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)
|
||||
// Paie (leaf, id 11)
|
||||
// Bonus (leaf, id 12)
|
||||
// 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)
|
||||
// Alimentation (INTERMEDIATE parent, id 20)
|
||||
// É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(20, "Alimentation", [500, 800], "expense", { is_parent: true, 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(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
|
||||
];
|
||||
|
|
@ -70,6 +73,7 @@ function buildTree(): OverTimeRow[] {
|
|||
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));
|
||||
const names = (rows: OverTimeRow[]) => rows.map((r) => r.categoryName);
|
||||
|
||||
describe("overTimeTableModel — storage key", () => {
|
||||
it("uses a trends-specific key, distinct from the comparable tables", () => {
|
||||
|
|
@ -79,13 +83,18 @@ describe("overTimeTableModel — storage key", () => {
|
|||
});
|
||||
|
||||
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);
|
||||
it("keys parents/leaves injectively (p:<id>) and climbs parent_id", () => {
|
||||
const revenus = buildTree()[0]; // parent
|
||||
const paie = buildTree()[1]; // leaf under Revenus
|
||||
const alimentation = buildTree()[4]; // intermediate parent
|
||||
expect(acc.keyOf(revenus)).toBe("p:1");
|
||||
expect(acc.parentKeyOf(revenus)).toBeNull(); // root
|
||||
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).
|
||||
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);
|
||||
|
||||
// Expense carries the intermediate parent (Alimentation) too, parent-first.
|
||||
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
||||
expect(expense.rows.map((r) => r.categoryName)).toEqual([
|
||||
expect(names(expense.rows)).toEqual([
|
||||
"Dépenses",
|
||||
"Alimentation",
|
||||
"Épicerie",
|
||||
"Resto",
|
||||
"Loyer",
|
||||
|
|
@ -114,7 +125,7 @@ describe("overTimeTableModel.groupOverTimeSections", () => {
|
|||
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 analysis = computeOverTimeResults(data);
|
||||
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
|
||||
|
|
@ -125,7 +136,8 @@ describe("overTimeTableModel.groupOverTimeSections", () => {
|
|||
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).
|
||||
// É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);
|
||||
});
|
||||
|
||||
|
|
@ -148,12 +160,13 @@ describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
|
|||
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"]);
|
||||
expect(names(visIncome)).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"]);
|
||||
// Dépenses folds its whole subtree (Alimentation + its leaves); the top-level
|
||||
// leaf Loyer always stays.
|
||||
expect(names(visExpense)).toEqual(["Dépenses", "Loyer"]);
|
||||
});
|
||||
|
||||
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 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"]);
|
||||
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["p:1"])));
|
||||
expect(names(visible)).toEqual(["Revenus", "Paie", "Bonus"]);
|
||||
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", () => {
|
||||
const data = makeData(MONTHS, buildTree());
|
||||
const analysis = computeOverTimeResults(data);
|
||||
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
|
||||
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
|
||||
// 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);
|
||||
expect(expense.total).toBe(3300);
|
||||
|
||||
// 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"])));
|
||||
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["p:1"])));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
|
|||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Collapse groups keyed by category id; a row is hidden when any ancestor
|
||||
* (walking parent_id) is collapsed, so every parent level is collapsible. 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),
|
||||
keyOf: (row) => `p:${row.categoryId}`,
|
||||
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
|
||||
depthOf: (row) => row.depth,
|
||||
isParent: (row) => row.is_parent,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,93 +1,133 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
type CollapseAccessors,
|
||||
collapsibleKeys,
|
||||
isCollapsedFor,
|
||||
parseStoredExpanded,
|
||||
serializeExpanded,
|
||||
visibleRows,
|
||||
} from "../utils/collapsibleRows";
|
||||
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||
|
||||
export interface CollapsibleGroups<T> {
|
||||
/** Filters a section's rows down to the ones visible under the current state. */
|
||||
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;
|
||||
/** Flip one group between collapsed and expanded, then persist. */
|
||||
toggle: (row: T) => void;
|
||||
/** Expand every collapsible group found in `rows`, then persist. */
|
||||
expandAll: (rows: T[]) => void;
|
||||
/** Collapse every group (back to the default), then persist. */
|
||||
collapseAll: () => void;
|
||||
/** Collapse every collapsible group found in `rows`, then persist. */
|
||||
collapseAll: (rows: T[]) => void;
|
||||
/** True when `rows` has at least one group and all of them are expanded. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-report collapse/expand state for the hierarchical comparable tables
|
||||
* (issue #254). Groups are collapsed by default (issue #260): the persisted
|
||||
* 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.
|
||||
* Per-report collapse/expand state for the hierarchical tables (issue #254/#265),
|
||||
* generalised to every hierarchy level (issue #288).
|
||||
*
|
||||
* Persistence uses localStorage, the same synchronous store the sibling
|
||||
* "subtotals on top/bottom" toggle already uses in these tables. State is kept
|
||||
* per report via a distinct `storageKey`.
|
||||
* The persisted value is the set of keys whose state DIFFERS from the default
|
||||
* (see `isCollapsedFor`), so `defaultExpanded: false` + an empty set = everything
|
||||
* 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>(
|
||||
storageKey: string,
|
||||
storageKey: string | null,
|
||||
acc: CollapseAccessors<T>,
|
||||
options?: { defaultExpanded?: boolean },
|
||||
): CollapsibleGroups<T> {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() =>
|
||||
parseStoredExpanded(
|
||||
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
|
||||
),
|
||||
);
|
||||
const defaultExpanded = options?.defaultExpanded ?? false;
|
||||
|
||||
// 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(
|
||||
(next: Set<string>) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, serializeExpanded(next));
|
||||
} catch {
|
||||
// Best-effort: a write failure just means the next session falls back
|
||||
// to the collapsed default.
|
||||
setFlipped(next);
|
||||
if (storageKey !== null) {
|
||||
void setPreference(storageKey, serializeExpanded(next)).catch(() => {
|
||||
// Best-effort: a write failure just means the next session falls back
|
||||
// to the default state.
|
||||
});
|
||||
}
|
||||
setExpanded(next);
|
||||
},
|
||||
[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 toggle = useCallback(
|
||||
(row: T) => {
|
||||
const key = acc.keyOf(row);
|
||||
const next = new Set(expanded);
|
||||
const next = new Set(flipped);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
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(
|
||||
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
|
||||
[acc, persist],
|
||||
(rows: T[]) => persist(defaultExpanded ? new Set() : new Set(collapsibleKeys(rows, acc))),
|
||||
[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 allExpanded = useCallback(
|
||||
(rows: T[]) => {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -1,102 +1,234 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
type CollapseAccessors,
|
||||
MAX_TREE_DEPTH,
|
||||
collapsibleKeys,
|
||||
isCollapsedFor,
|
||||
parseStoredExpanded,
|
||||
serializeExpanded,
|
||||
visibleRows,
|
||||
} 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 {
|
||||
k: string;
|
||||
d: number;
|
||||
p: boolean;
|
||||
id: number;
|
||||
parent: number | null;
|
||||
is_parent: boolean;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const acc: CollapseAccessors<Row> = {
|
||||
keyOf: (r) => r.k,
|
||||
depthOf: (r) => r.d,
|
||||
isParent: (r) => r.p,
|
||||
keyOf: (r) => `p:${r.id}`,
|
||||
parentKeyOf: (r) => (r.parent === null ? null : `p:${r.parent}`),
|
||||
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:
|
||||
// Housing (parent, d0)
|
||||
// Rent (leaf, d1)
|
||||
// Utilities (parent, d1) <- intermediate parent
|
||||
// Power (leaf, d2)
|
||||
// Salary (parent, d0)
|
||||
// Paycheck (leaf, d1)
|
||||
// Misc (leaf, d0) <- top-level leaf, not a group
|
||||
const tree: Row[] = [
|
||||
row("housing", 0, true),
|
||||
row("rent", 1, false),
|
||||
row("utilities", 1, true),
|
||||
row("power", 2, false),
|
||||
row("salary", 0, true),
|
||||
row("paycheck", 1, false),
|
||||
row("misc", 0, false),
|
||||
// A three-level tree (income → expense → transfer shape is irrelevant here; the
|
||||
// point is the depth):
|
||||
// Housing (parent, id 1)
|
||||
// Rent (leaf, id 11)
|
||||
// Utilities (INTERMEDIATE parent, id 12)
|
||||
// Power (leaf, id 121)
|
||||
// Water (leaf, id 122)
|
||||
// Salary (parent, id 2)
|
||||
// Paycheck (leaf, id 21)
|
||||
// Misc (top-level leaf, id 3)
|
||||
const dfsTree: Row[] = [
|
||||
r(1, null, true, 0), // Housing
|
||||
r(11, 1, false, 1), // Rent
|
||||
r(12, 1, true, 1), // Utilities (intermediate)
|
||||
r(121, 12, false, 2), // Power
|
||||
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.
|
||||
const isCollapsed = (expanded: Set<string>) => (r: Row) => !expanded.has(r.k);
|
||||
// The EXACT same tree, emitted level-order (BFS) — the order the budget grid
|
||||
// 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", () => {
|
||||
it("collapses every group by default (empty expanded set)", () => {
|
||||
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
||||
// Only depth-0 rows survive: the two group headers + the top-level leaf.
|
||||
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "misc"]);
|
||||
/** Report polarity: a group is collapsed unless its key is in the expanded set. */
|
||||
const isCollapsed = (expanded: Set<string>) => (row: Row) => !expanded.has(acc.keyOf(row));
|
||||
const ids = (rows: Row[]) => rows.map((row) => row.id);
|
||||
const idSet = (rows: Row[]) => new Set(ids(rows));
|
||||
|
||||
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", () => {
|
||||
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing"])));
|
||||
expect(visible.map((r) => r.k)).toEqual([
|
||||
"housing",
|
||||
"rent",
|
||||
"utilities",
|
||||
"power",
|
||||
"salary",
|
||||
"misc",
|
||||
]);
|
||||
it("expanding a root reveals ONLY its direct children, not its grandchildren", () => {
|
||||
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1"])));
|
||||
// Rent + Utilities appear; Power/Water (grandchildren) stay folded because the
|
||||
// intermediate Utilities is still collapsed.
|
||||
expect(ids(visible)).toEqual([1, 11, 12, 2, 3]);
|
||||
expect(visible.some((row) => row.id === 121)).toBe(false);
|
||||
expect(visible.some((row) => row.id === 122)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps groups independent — expanding one leaves the others collapsed", () => {
|
||||
const visible = visibleRows(tree, acc, isCollapsed(new Set(["salary"])));
|
||||
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "paycheck", "misc"]);
|
||||
it("expanding a root AND its intermediate reveals the leaves", () => {
|
||||
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1", "p:12"])));
|
||||
expect(ids(visible)).toEqual([1, 11, 12, 121, 122, 2, 3]);
|
||||
});
|
||||
|
||||
it("shows everything when all groups are expanded", () => {
|
||||
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"])));
|
||||
expect(visible).toHaveLength(tree.length);
|
||||
it("a collapsed ancestor always wins: expanding an intermediate whose root is collapsed reveals nothing", () => {
|
||||
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:12"])));
|
||||
// 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", () => {
|
||||
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
||||
expect(visible.some((r) => r.k === "misc")).toBe(true);
|
||||
it("keeps groups independent — expanding Salary leaves Housing folded", () => {
|
||||
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:2"])));
|
||||
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", () => {
|
||||
it("lists only top-level parent rows, in order", () => {
|
||||
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]);
|
||||
it("lists EVERY parent, intermediates included, in encounter order", () => {
|
||||
// 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", () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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("").size).toBe(0);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
|
@ -110,7 +242,7 @@ describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
|
|||
});
|
||||
|
||||
it("round-trips through serialize", () => {
|
||||
const set = new Set(["a", "b"]);
|
||||
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]);
|
||||
const set = new Set(["p:1", "p:12"]);
|
||||
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["p:1", "p:12"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,71 +1,104 @@
|
|||
/**
|
||||
* Collapse/expand of top-level category groups in the hierarchical comparable
|
||||
* reports (real-vs-real Compare + Budget-vs-Actual) — issue #254.
|
||||
* Collapse/expand of category groups in the hierarchical reports (real-vs-real
|
||||
* 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
|
||||
* `useCollapsibleGroups` hook.
|
||||
*
|
||||
* A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps
|
||||
* its own subtotal row visible but hides its entire subtree (every following
|
||||
* depth ≥ 1 row until the next depth-0 row). Rows must arrive in depth-first
|
||||
* order — the order the compare/budget services already emit them in, and the
|
||||
* same order `reorderRows` relies on.
|
||||
* Visibility is decided by an ANCESTOR WALK, not by row adjacency: a row is
|
||||
* hidden iff *any* of its ancestors is collapsed. We climb `parentKeyOf` from a
|
||||
* row until we reach a root (null), an ancestor outside this section (⇒ visible),
|
||||
* or a collapsed ancestor (⇒ hidden). This is independent of the order rows
|
||||
* 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
|
||||
* table can supply the *exact* depth expression it renders with (CategoryDelta
|
||||
* uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from
|
||||
* `parent_id`). Keeping the collapse depth and the indentation depth identical
|
||||
* guarantees the hidden rows are exactly the indented descendants.
|
||||
* The accessors are supplied by each consumer so every table can key + climb the
|
||||
* exact hierarchy it renders. Keys are prefixed `p:` so they are injective: a
|
||||
* parent subtotal row and its own "(direct)" leaf share a category id but the
|
||||
* leaf is never a parent, so only the subtotal is indexed — the leaf simply
|
||||
* 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. */
|
||||
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;
|
||||
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */
|
||||
depthOf: (row: T) => number;
|
||||
/** Key of this row's parent (`p:<parent_id>`), or null at a root. */
|
||||
parentKeyOf: (row: T) => string | null;
|
||||
/** True when the row is a group header / subtotal (has children). */
|
||||
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
|
||||
* collapsed. `isCollapsed` is consulted only for top-level parent rows; a
|
||||
* collapsed one keeps its own (subtotal) row and drops its whole subtree.
|
||||
* Keeps every row that is currently visible given which groups are collapsed. A
|
||||
* row is dropped iff any ancestor (walking `parentKeyOf`) is collapsed; a missing
|
||||
* ancestor (parent lives in another section) leaves the row visible.
|
||||
*/
|
||||
export function visibleRows<T>(
|
||||
rows: T[],
|
||||
acc: CollapseAccessors<T>,
|
||||
isCollapsed: (row: T) => boolean,
|
||||
): T[] {
|
||||
const out: T[] = [];
|
||||
// While true we are inside a collapsed top-level parent's subtree and drop
|
||||
// every deeper row until the next depth-0 row re-opens the flow.
|
||||
let hidingSubtree = false;
|
||||
for (const row of rows) {
|
||||
if (acc.depthOf(row) === 0) {
|
||||
out.push(row);
|
||||
hidingSubtree = acc.isParent(row) && isCollapsed(row);
|
||||
} else if (!hidingSubtree) {
|
||||
out.push(row);
|
||||
// Index ONLY parents: a "(direct)" leaf shares its parent's category id but is
|
||||
// never a parent itself, so it never clobbers the subtotal it points at.
|
||||
const parents = new Map<string, T>();
|
||||
for (const r of rows) if (acc.isParent(r)) parents.set(acc.keyOf(r), r);
|
||||
|
||||
const hiddenByAncestor = (row: T): boolean => {
|
||||
let key = acc.parentKeyOf(row);
|
||||
let hops = 0;
|
||||
while (key !== null && hops++ < MAX_TREE_DEPTH) {
|
||||
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 out;
|
||||
return false;
|
||||
};
|
||||
|
||||
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[] {
|
||||
const keys: string[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the persisted value into the set of *expanded* group keys. The report
|
||||
* default is "everything collapsed" (issue #254/#260), so an absent or corrupt
|
||||
* value yields an empty set — i.e. all groups collapsed on a first-ever visit.
|
||||
* Polarity of the collapse state. The persisted Set holds the keys whose state
|
||||
* DIFFERS from the default, which lets one hook serve both polarities without a
|
||||
* 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> {
|
||||
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"));
|
||||
}
|
||||
} catch {
|
||||
// Corrupt value: fall back to the collapsed default.
|
||||
// Corrupt value: fall back to the default state.
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
/** Serialises the set of expanded group keys for persistence. */
|
||||
export function serializeExpanded(expanded: Set<string>): string {
|
||||
return JSON.stringify([...expanded]);
|
||||
/** Serialises the flipped-key set for persistence. */
|
||||
export function serializeExpanded(flipped: Set<string>): string {
|
||||
return JSON.stringify([...flipped]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue