feat(reports): collapse category hierarchy at every level (socle + 3 reports)
All checks were successful
PR Check / rust (pull_request) Successful in 23m0s
PR Check / frontend (pull_request) Successful in 2m30s

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:
le king fu 2026-07-15 20:58:18 -04:00
parent 524fe162ea
commit 48adb3db77
11 changed files with 434 additions and 250 deletions

View file

@ -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é

View file

@ -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

View file

@ -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} />}

View file

@ -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} />}

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";
// 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} />}

View file

@ -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);
});
});

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
* 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,
};

View file

@ -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 };

View file

@ -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"]);
});
});

View file

@ -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]);
}