feat(reports): collapse/expand sub-categories in comparable reports (#254)
All checks were successful
PR Check / rust (pull_request) Successful in 22m52s
PR Check / frontend (pull_request) Successful in 2m24s

The two hierarchical comparable reports — real-vs-real Compare and
real-vs-budget — now let the user collapse or expand each top-level
category's sub-categories. Groups start collapsed by default (#260): only
the parent's subtotal row shows until the user expands it, and each expanded
group is remembered per report.

- New pure module utils/collapsibleRows.ts (visibility filter, group-key
  extraction, expanded-set (de)serialization) + useCollapsibleGroups hook
  wrapping the localStorage-persisted expanded set. Persisting the *expanded*
  set (not the collapsed one) makes "all collapsed" the zero/default state.
- A chevron toggles each top-level parent; an "Expand all / Collapse all"
  button toggles them together. Section subtotals and result lines keep
  summing every leaf, so collapsing never changes any total.
- Accessors mirror each table's own depth/parent logic so hidden rows are
  exactly a group's indented descendants.
- i18n keys reports.collapse.{expandAll,collapseAll} (FR/EN); CHANGELOG.

CategoryOverTimeTable (Trends -> by category) is intentionally left out: its
rows are a flat top-N category list with no parent/child hierarchy to fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-06 20:18:52 -04:00
parent 36385320a6
commit 616cccec37
9 changed files with 437 additions and 20 deletions

View file

@ -2,6 +2,10 @@
## [Non publié] ## [Non publié]
### Ajouté
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05
### Modifié ### Modifié

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Added
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05
### Changed ### Changed

View file

@ -1,8 +1,10 @@
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types"; import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { new Intl.NumberFormat("en-CA", {
@ -26,6 +28,17 @@ interface BudgetVsActualTableProps {
const STORAGE_KEY = "subtotals-position"; 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.
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
keyOf: (row) => String(row.category_id),
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
isParent: (row) => row.is_parent,
};
const BVA_EXPANDED_STORAGE_KEY = "reports-bva-expanded";
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
@ -41,6 +54,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
}); });
}; };
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
const groups = useCollapsibleGroups<BudgetVsActualRow>(
BVA_EXPANDED_STORAGE_KEY,
BVA_COLLAPSE_ACCESSORS,
);
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -88,9 +107,22 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null; const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null; const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
const hasGroups = groups.groupCount(data) > 0;
const allExpanded = groups.allExpanded(data);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : 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} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button <button
onClick={toggleSubtotals} onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors" className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
@ -163,11 +195,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
{section.label} {section.label}
</td> </td>
</tr> </tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => { {reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
const isParent = row.is_parent; const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0; const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1; const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr <tr
@ -184,6 +217,25 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]` ? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]` : `${paddingClass} bg-[var(--card)]`
}`}> }`}>
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</button>
) : (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -191,6 +243,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
/> />
{row.category_name} {row.category_name}
</span> </span>
)}
</td> </td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}> <td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.monthActual)} {cadFormatter(row.monthActual)}

View file

@ -1,8 +1,10 @@
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { CategoryDelta } from "../../shared/types"; import type { CategoryDelta } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults"; import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
export interface ComparePeriodTableProps { export interface ComparePeriodTableProps {
@ -57,6 +59,16 @@ function deltaColor(value: number, higherIsBetter: boolean): string {
const STORAGE_KEY = "compare-subtotals-position"; const STORAGE_KEY = "compare-subtotals-position";
// Collapse groups keyed by category id; depth/parent mirror the render logic
// below so the hidden rows are exactly a group's indented descendants.
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
keyOf: (row) => String(row.categoryId),
depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent ?? false,
};
const COMPARE_EXPANDED_STORAGE_KEY = "reports-compare-expanded";
const COL_COUNT = 9; const COL_COUNT = 9;
export default function ComparePeriodTable({ export default function ComparePeriodTable({
@ -81,6 +93,12 @@ export default function ComparePeriodTable({
}); });
}; };
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
const groups = useCollapsibleGroups<CategoryDelta>(
COMPARE_EXPANDED_STORAGE_KEY,
COMPARE_COLLAPSE_ACCESSORS,
);
const monthPrevLabel = previousLabel; const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel; const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
@ -131,11 +149,12 @@ export default function ComparePeriodTable({
{sectionLabels[section.type]} {sectionLabels[section.type]}
</td> </td>
</tr> </tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => { {reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
const isParent = row.is_parent ?? false; const isParent = row.is_parent ?? false;
const depth = row.depth ?? 0; const depth = row.depth ?? 0;
const isTopParent = isParent && depth === 0; const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1; const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass = const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
@ -158,6 +177,25 @@ export default function ComparePeriodTable({
: `${paddingClass} bg-[var(--card)]` : `${paddingClass} bg-[var(--card)]`
}`} }`}
> >
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</button>
) : (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -165,6 +203,7 @@ export default function ComparePeriodTable({
/> />
{row.categoryName} {row.categoryName}
</span> </span>
)}
</td> </td>
{/* Monthly block */} {/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
@ -301,9 +340,22 @@ export default function ComparePeriodTable({
); );
}; };
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : 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} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button <button
onClick={toggleSubtotals} onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors" className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"

View file

@ -0,0 +1,94 @@
import { useCallback, useState } from "react";
import {
type CollapseAccessors,
collapsibleKeys,
parseStoredExpanded,
serializeExpanded,
visibleRows,
} from "../utils/collapsibleRows";
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). */
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;
/** 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`. */
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.
*
* 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`.
*/
export function useCollapsibleGroups<T>(
storageKey: string,
acc: CollapseAccessors<T>,
): CollapsibleGroups<T> {
const [expanded, setExpanded] = useState<Set<string>>(() =>
parseStoredExpanded(
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
),
);
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.
}
setExpanded(next);
},
[storageKey],
);
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]);
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);
if (next.has(key)) next.delete(key);
else next.add(key);
persist(next);
},
[expanded, acc, persist],
);
const expandAll = useCallback(
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
[acc, persist],
);
const collapseAll = useCallback(() => persist(new Set()), [persist]);
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));
},
[expanded, acc],
);
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
}

View file

@ -379,6 +379,10 @@
"budgetVsActual": "Budget vs Actual", "budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top", "subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom", "subtotalsOnBottom": "Subtotals on bottom",
"collapse": {
"expandAll": "Expand all",
"collapseAll": "Collapse all"
},
"detail": { "detail": {
"showAmounts": "Show amounts", "showAmounts": "Show amounts",
"hideAmounts": "Hide amounts" "hideAmounts": "Hide amounts"

View file

@ -379,6 +379,10 @@
"budgetVsActual": "Budget vs Réel", "budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut", "subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas", "subtotalsOnBottom": "Sous-totaux en bas",
"collapse": {
"expandAll": "Tout déplier",
"collapseAll": "Tout replier"
},
"detail": { "detail": {
"showAmounts": "Afficher les montants", "showAmounts": "Afficher les montants",
"hideAmounts": "Masquer les montants" "hideAmounts": "Masquer les montants"

View file

@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import {
type CollapseAccessors,
collapsibleKeys,
parseStoredExpanded,
serializeExpanded,
visibleRows,
} from "./collapsibleRows";
/** Tiny hierarchy row for the tests: key, depth, is-parent. */
interface Row {
k: string;
d: number;
p: boolean;
}
const acc: CollapseAccessors<Row> = {
keyOf: (r) => r.k,
depthOf: (r) => r.d,
isParent: (r) => r.p,
};
const row = (k: string, d: number, p: boolean): Row => ({ k, d, p });
// 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),
];
// 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);
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"]);
});
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("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("shows everything when all groups are expanded", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"])));
expect(visible).toHaveLength(tree.length);
});
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);
});
});
describe("collapsibleRows.collapsibleKeys", () => {
it("lists only top-level parent rows, in order", () => {
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]);
});
it("returns [] when there is no hierarchy", () => {
const flat = [row("a", 0, false), row("b", 0, false)];
expect(collapsibleKeys(flat, acc)).toEqual([]);
});
});
describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
it("treats absent storage as the collapsed default (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"]);
});
it("falls back to empty on corrupt or non-array JSON", () => {
expect(parseStoredExpanded("{not json").size).toBe(0);
expect(parseStoredExpanded('{"a":1}').size).toBe(0);
expect(parseStoredExpanded("42").size).toBe(0);
});
it("drops non-string members defensively", () => {
expect([...parseStoredExpanded('["ok", 3, null, "yes"]')].sort()).toEqual(["ok", "yes"]);
});
it("round-trips through serialize", () => {
const set = new Set(["a", "b"]);
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]);
});
});

View file

@ -0,0 +1,86 @@
/**
* Collapse/expand of top-level category groups in the hierarchical comparable
* reports (real-vs-real Compare + Budget-vs-Actual) issue #254.
*
* 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.
*
* 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.
*/
/** How the collapse logic reads hierarchy position + identity off a row. */
export interface CollapseAccessors<T> {
/** Stable per-group key (a category id, stringified). */
keyOf: (row: T) => string;
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */
depthOf: (row: T) => number;
/** True when the row is a group header / subtotal (has children). */
isParent: (row: T) => boolean;
}
/**
* 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.
*/
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);
}
}
return out;
}
/** Keys of every collapsible group (top-level parent rows), 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));
}
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.
*/
export function parseStoredExpanded(raw: string | null): Set<string> {
if (!raw) return new Set();
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(parsed.filter((k): k is string => typeof k === "string"));
}
} catch {
// Corrupt value: fall back to the collapsed default.
}
return new Set();
}
/** Serialises the set of expanded group keys for persistence. */
export function serializeExpanded(expanded: Set<string>): string {
return JSON.stringify([...expanded]);
}