feat(rapports) : repli/depliage a chaque niveau de la hierarchie (socle + 3 rapports) #292
11 changed files with 434 additions and 250 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- Rapports : les trois tableaux de rapport hiérarchiques (Comparaison réel-vs-réel, réel-vs-budget, et Tendances par catégorie) permettent désormais de **replier ou déplier chaque niveau de la hiérarchie de catégories**, plus seulement le premier. Chaque catégorie parente — y compris les intermédiaires — porte son propre chevron ; en déplier une révèle ses enfants directs (eux-mêmes repliés), pour descendre un niveau à la fois. Les tableaux s'ouvrent entièrement repliés (seules les catégories de premier niveau visibles), « Tout déplier » ouvre tous les niveaux d'un coup, et vos choix de repli sont désormais enregistrés **par profil** — dans la base de données du profil, donc détruits avec lui — au lieu du navigateur. Les sous-totaux et les lignes de résultat restent rigoureusement inchangés quel que soit ce que vous repliez (#288).
|
||||||
|
|
||||||
## [0.13.0] - 2026-07-12
|
## [0.13.0] - 2026-07-12
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reports: the three hierarchical report tables (real-vs-real Compare, real-vs-budget Compare, and Trends by category) now let you **collapse or expand every level of the category hierarchy**, not just the top level. Every parent category — including the intermediate ones — carries its own chevron; expanding one reveals its direct children (themselves collapsed), so you drill down one level at a time. The tables open fully collapsed (only the top-level categories visible), "Expand all" opens every level at once, and your collapse choices are now saved **per profile** — in the profile's own database, so they are destroyed with the profile — instead of in the browser. Subtotals and result lines stay exactly the same whatever you fold away (#288).
|
||||||
|
|
||||||
## [0.13.0] - 2026-07-12
|
## [0.13.0] - 2026-07-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ interface BudgetVsActualTableProps {
|
||||||
|
|
||||||
const STORAGE_KEY = "subtotals-position";
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
// Collapse groups keyed by category id; depth mirrors the render logic below
|
// Collapse groups keyed by category id; a row is hidden when any ancestor
|
||||||
// (a missing depth is derived from parent_id) so hidden rows are exactly a
|
// (walking parent_id) is collapsed, so every parent level is collapsible.
|
||||||
// group's indented descendants.
|
|
||||||
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
|
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
|
||||||
keyOf: (row) => String(row.category_id),
|
keyOf: (row) => `p:${row.category_id}`,
|
||||||
|
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
|
||||||
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
|
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
|
||||||
isParent: (row) => row.is_parent,
|
isParent: (row) => row.is_parent,
|
||||||
};
|
};
|
||||||
|
|
@ -116,11 +116,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||||
const isTopParent = isParent && depth === 0;
|
const isTopParent = isParent && depth === 0;
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
const collapsed = isTopParent && groups.isCollapsed(row);
|
const collapsed = isParent && groups.isCollapsed(row);
|
||||||
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||||
|
aria-level={isParent ? depth + 1 : undefined}
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
||||||
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
||||||
|
|
@ -133,7 +134,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
}`}>
|
}`}>
|
||||||
{isTopParent ? (
|
{isParent ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => groups.toggle(row)}
|
onClick={() => groups.toggle(row)}
|
||||||
|
|
@ -255,7 +256,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
{hasGroups && (
|
{hasGroups && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
onClick={() => (allExpanded ? groups.collapseAll(data) : groups.expandAll(data))}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
>
|
>
|
||||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||||
|
|
|
||||||
|
|
@ -93,12 +93,13 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
||||||
const depth = row.depth;
|
const depth = row.depth;
|
||||||
const isTopParent = isParent && depth === 0;
|
const isTopParent = isParent && depth === 0;
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
const collapsed = isTopParent && groups.isCollapsed(row);
|
const collapsed = isParent && groups.isCollapsed(row);
|
||||||
const paddingClass =
|
const paddingClass =
|
||||||
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
||||||
|
aria-level={isParent ? depth + 1 : undefined}
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
isTopParent
|
isTopParent
|
||||||
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
|
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
|
||||||
|
|
@ -116,7 +117,7 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isTopParent ? (
|
{isParent ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => groups.toggle(row)}
|
onClick={() => groups.toggle(row)}
|
||||||
|
|
@ -211,7 +212,7 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
||||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data.tree))}
|
onClick={() => (allExpanded ? groups.collapseAll(data.tree) : groups.expandAll(data.tree))}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
>
|
>
|
||||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
const STORAGE_KEY = "compare-subtotals-position";
|
||||||
|
|
||||||
// Collapse groups keyed by category id; depth/parent mirror the render logic
|
// Collapse groups keyed by category id; a row is hidden when any ancestor
|
||||||
// below so the hidden rows are exactly a group's indented descendants.
|
// (walking parent_id) is collapsed, so every parent level is collapsible.
|
||||||
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
|
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
|
||||||
keyOf: (row) => String(row.categoryId),
|
keyOf: (row) => `p:${row.categoryId}`,
|
||||||
|
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
|
||||||
depthOf: (row) => row.depth ?? 0,
|
depthOf: (row) => row.depth ?? 0,
|
||||||
isParent: (row) => row.is_parent ?? false,
|
isParent: (row) => row.is_parent ?? false,
|
||||||
};
|
};
|
||||||
|
|
@ -154,12 +155,13 @@ export default function ComparePeriodTable({
|
||||||
const depth = row.depth ?? 0;
|
const depth = row.depth ?? 0;
|
||||||
const isTopParent = isParent && depth === 0;
|
const isTopParent = isParent && depth === 0;
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
const collapsed = isTopParent && groups.isCollapsed(row);
|
const collapsed = isParent && groups.isCollapsed(row);
|
||||||
const paddingClass =
|
const paddingClass =
|
||||||
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
||||||
|
aria-level={isParent ? depth + 1 : undefined}
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
isTopParent
|
isTopParent
|
||||||
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
|
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
|
||||||
|
|
@ -177,7 +179,7 @@ export default function ComparePeriodTable({
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isTopParent ? (
|
{isParent ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => groups.toggle(row)}
|
onClick={() => groups.toggle(row)}
|
||||||
|
|
@ -349,7 +351,7 @@ export default function ComparePeriodTable({
|
||||||
{hasGroups && (
|
{hasGroups && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(rows))}
|
onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
>
|
>
|
||||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||||
|
|
|
||||||
|
|
@ -45,23 +45,26 @@ function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
|
||||||
|
|
||||||
const MONTHS = ["2025-01", "2025-02"];
|
const MONTHS = ["2025-01", "2025-02"];
|
||||||
|
|
||||||
// A realistic parent-first depth-first tree (income → expense → transfer):
|
// A realistic parent-first depth-first tree, three levels deep on the expense
|
||||||
|
// side (income → expense → transfer):
|
||||||
// Revenus (parent, id 1)
|
// Revenus (parent, id 1)
|
||||||
// Paie (leaf, id 11)
|
// Paie (leaf, id 11)
|
||||||
// Bonus (leaf, id 12)
|
// Bonus (leaf, id 12)
|
||||||
// Dépenses (parent, id 2)
|
// Dépenses (parent, id 2)
|
||||||
// Épicerie (leaf, id 21)
|
// Alimentation (INTERMEDIATE parent, id 20)
|
||||||
// Resto (leaf, id 22)
|
// Épicerie (leaf, id 21)
|
||||||
// Loyer (top-level leaf, id 23)
|
// Resto (leaf, id 22)
|
||||||
// Épargne (transfer leaf, id 3)
|
// Loyer (top-level leaf, id 23)
|
||||||
|
// Épargne (transfer leaf, id 3)
|
||||||
function buildTree(): OverTimeRow[] {
|
function buildTree(): OverTimeRow[] {
|
||||||
return [
|
return [
|
||||||
mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
|
mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
|
||||||
mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
|
mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
|
||||||
mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }),
|
mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }),
|
||||||
mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
|
mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
|
||||||
mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 2, depth: 1 }),
|
mkRow(20, "Alimentation", [500, 800], "expense", { is_parent: true, parent_id: 2, depth: 1 }),
|
||||||
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 2, depth: 1 }),
|
mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 20, depth: 2 }),
|
||||||
|
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 20, depth: 2 }),
|
||||||
mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
|
mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
|
||||||
mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
|
mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
|
||||||
];
|
];
|
||||||
|
|
@ -70,6 +73,7 @@ function buildTree(): OverTimeRow[] {
|
||||||
const acc = OVERTIME_COLLAPSE_ACCESSORS;
|
const acc = OVERTIME_COLLAPSE_ACCESSORS;
|
||||||
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */
|
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */
|
||||||
const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
|
const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
|
||||||
|
const names = (rows: OverTimeRow[]) => rows.map((r) => r.categoryName);
|
||||||
|
|
||||||
describe("overTimeTableModel — storage key", () => {
|
describe("overTimeTableModel — storage key", () => {
|
||||||
it("uses a trends-specific key, distinct from the comparable tables", () => {
|
it("uses a trends-specific key, distinct from the comparable tables", () => {
|
||||||
|
|
@ -79,13 +83,18 @@ describe("overTimeTableModel — storage key", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("overTimeTableModel — collapse accessors", () => {
|
describe("overTimeTableModel — collapse accessors", () => {
|
||||||
it("keys by category id and reads the snake_case hierarchy block", () => {
|
it("keys parents/leaves injectively (p:<id>) and climbs parent_id", () => {
|
||||||
const parent = buildTree()[0];
|
const revenus = buildTree()[0]; // parent
|
||||||
const leaf = buildTree()[1];
|
const paie = buildTree()[1]; // leaf under Revenus
|
||||||
expect(acc.keyOf(parent)).toBe("1");
|
const alimentation = buildTree()[4]; // intermediate parent
|
||||||
expect(acc.depthOf(leaf)).toBe(1);
|
expect(acc.keyOf(revenus)).toBe("p:1");
|
||||||
expect(acc.isParent(parent)).toBe(true);
|
expect(acc.parentKeyOf(revenus)).toBeNull(); // root
|
||||||
expect(acc.isParent(leaf)).toBe(false);
|
expect(acc.parentKeyOf(paie)).toBe("p:1"); // climbs to Revenus
|
||||||
|
expect(acc.parentKeyOf(alimentation)).toBe("p:2"); // climbs to Dépenses
|
||||||
|
expect(acc.depthOf(paie)).toBe(1);
|
||||||
|
expect(acc.isParent(revenus)).toBe(true);
|
||||||
|
expect(acc.isParent(paie)).toBe(false);
|
||||||
|
expect(acc.isParent(alimentation)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -101,12 +110,14 @@ describe("overTimeTableModel.groupOverTimeSections", () => {
|
||||||
|
|
||||||
// Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
|
// Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
const income = nonTransferSections.find((s) => s.type === "income")!;
|
||||||
expect(income.rows.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
|
expect(names(income.rows)).toEqual(["Revenus", "Paie", "Bonus"]);
|
||||||
expect(income.rows[0].is_parent).toBe(true);
|
expect(income.rows[0].is_parent).toBe(true);
|
||||||
|
|
||||||
|
// Expense carries the intermediate parent (Alimentation) too, parent-first.
|
||||||
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
||||||
expect(expense.rows.map((r) => r.categoryName)).toEqual([
|
expect(names(expense.rows)).toEqual([
|
||||||
"Dépenses",
|
"Dépenses",
|
||||||
|
"Alimentation",
|
||||||
"Épicerie",
|
"Épicerie",
|
||||||
"Resto",
|
"Resto",
|
||||||
"Loyer",
|
"Loyer",
|
||||||
|
|
@ -114,7 +125,7 @@ describe("overTimeTableModel.groupOverTimeSections", () => {
|
||||||
expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
|
expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps subtotals as the reducer's leaf sums — a parent is never double-counted", () => {
|
it("keeps subtotals as the reducer's leaf sums — parents (incl. intermediate) are never double-counted", () => {
|
||||||
const data = makeData(MONTHS, buildTree());
|
const data = makeData(MONTHS, buildTree());
|
||||||
const analysis = computeOverTimeResults(data);
|
const analysis = computeOverTimeResults(data);
|
||||||
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
|
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
|
||||||
|
|
@ -125,7 +136,8 @@ describe("overTimeTableModel.groupOverTimeSections", () => {
|
||||||
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });
|
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });
|
||||||
|
|
||||||
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
||||||
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded).
|
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses AND Alimentation
|
||||||
|
// parents excluded, even though Alimentation carries a [500,800] series).
|
||||||
expect(expense.total).toBe(3300);
|
expect(expense.total).toBe(3300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -148,12 +160,13 @@ describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
const income = nonTransferSections.find((s) => s.type === "income")!;
|
||||||
const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
|
const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
|
||||||
// Revenus stays (its own subtotal row); Paie/Bonus are folded away.
|
// Revenus stays (its own subtotal row); Paie/Bonus are folded away.
|
||||||
expect(visIncome.map((r) => r.categoryName)).toEqual(["Revenus"]);
|
expect(names(visIncome)).toEqual(["Revenus"]);
|
||||||
|
|
||||||
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
||||||
const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
|
const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
|
||||||
// Dépenses folds its children; the top-level leaf Loyer always stays.
|
// Dépenses folds its whole subtree (Alimentation + its leaves); the top-level
|
||||||
expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]);
|
// leaf Loyer always stays.
|
||||||
|
expect(names(visExpense)).toEqual(["Dépenses", "Loyer"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
|
it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
|
||||||
|
|
@ -161,28 +174,55 @@ describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
|
||||||
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
|
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
const income = nonTransferSections.find((s) => s.type === "income")!;
|
||||||
|
|
||||||
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
|
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["p:1"])));
|
||||||
expect(visible.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
|
expect(names(visible)).toEqual(["Revenus", "Paie", "Bonus"]);
|
||||||
expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
|
expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cascades across three levels: a root reveals only its direct child parent, not the leaves", () => {
|
||||||
|
const data = makeData(MONTHS, buildTree());
|
||||||
|
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
|
||||||
|
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
||||||
|
|
||||||
|
// Expand Dépenses only: Alimentation (direct child) shows, Loyer stays, but
|
||||||
|
// Épicerie/Resto (grandchildren under the still-collapsed Alimentation) do NOT.
|
||||||
|
const rootOnly = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2"])));
|
||||||
|
expect(names(rootOnly)).toEqual(["Dépenses", "Alimentation", "Loyer"]);
|
||||||
|
|
||||||
|
// Expand Dépenses AND Alimentation: the leaves finally appear.
|
||||||
|
const bothOpen = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2", "p:20"])));
|
||||||
|
expect(names(bothOpen)).toEqual(["Dépenses", "Alimentation", "Épicerie", "Resto", "Loyer"]);
|
||||||
|
|
||||||
|
// Expand the intermediate but NOT the root: a collapsed ancestor wins, nothing
|
||||||
|
// under Dépenses is revealed.
|
||||||
|
const intermediateOnly = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:20"])));
|
||||||
|
expect(names(intermediateOnly)).toEqual(["Dépenses", "Loyer"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
|
it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
|
||||||
const data = makeData(MONTHS, buildTree());
|
const data = makeData(MONTHS, buildTree());
|
||||||
const analysis = computeOverTimeResults(data);
|
const analysis = computeOverTimeResults(data);
|
||||||
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
|
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
|
||||||
const income = nonTransferSections.find((s) => s.type === "income")!;
|
const income = nonTransferSections.find((s) => s.type === "income")!;
|
||||||
|
const expense = nonTransferSections.find((s) => s.type === "expense")!;
|
||||||
|
|
||||||
// Figures come from the raw tree (never the visible rows), so they hold in
|
// Figures come from the raw tree (never the visible rows), so they hold in
|
||||||
// every collapse state.
|
// every collapse state.
|
||||||
expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income − 3300 expense
|
expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income − 3300 expense
|
||||||
expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
|
expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
|
||||||
expect(income.total).toBe(6500);
|
expect(income.total).toBe(6500);
|
||||||
|
expect(expense.total).toBe(3300);
|
||||||
|
|
||||||
// Folding vs expanding a group only changes how many rows are visible.
|
// Folding vs expanding a group only changes how many rows are visible.
|
||||||
const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
|
const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
|
||||||
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
|
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["p:1"])));
|
||||||
expect(collapsed.length).toBeLessThan(expanded.length);
|
expect(collapsed.length).toBeLessThan(expanded.length);
|
||||||
// The subtotal the table shows is identical in both states.
|
|
||||||
expect(income.total).toBe(6500);
|
// Expanding the intermediate expense level does not move the section subtotal.
|
||||||
|
const expenseAllOpen = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2", "p:20"])));
|
||||||
|
expect(expenseAllOpen.length).toBeGreaterThan(
|
||||||
|
visibleRows(expense.rows, acc, isCollapsed(new Set())).length,
|
||||||
|
);
|
||||||
|
expect(expense.total).toBe(3300);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapse groups keyed by category id; depth/parent mirror the tree the render
|
* Collapse groups keyed by category id; a row is hidden when any ancestor
|
||||||
* indents by, so a collapsed group hides exactly its indented descendants. The
|
* (walking parent_id) is collapsed, so every parent level is collapsible. The
|
||||||
* `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these
|
* `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these
|
||||||
* accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged.
|
* accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged.
|
||||||
*/
|
*/
|
||||||
export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = {
|
export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = {
|
||||||
keyOf: (row) => String(row.categoryId),
|
keyOf: (row) => `p:${row.categoryId}`,
|
||||||
|
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
|
||||||
depthOf: (row) => row.depth,
|
depthOf: (row) => row.depth,
|
||||||
isParent: (row) => row.is_parent,
|
isParent: (row) => row.is_parent,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,133 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
type CollapseAccessors,
|
type CollapseAccessors,
|
||||||
collapsibleKeys,
|
collapsibleKeys,
|
||||||
|
isCollapsedFor,
|
||||||
parseStoredExpanded,
|
parseStoredExpanded,
|
||||||
serializeExpanded,
|
serializeExpanded,
|
||||||
visibleRows,
|
visibleRows,
|
||||||
} from "../utils/collapsibleRows";
|
} from "../utils/collapsibleRows";
|
||||||
|
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||||
|
|
||||||
export interface CollapsibleGroups<T> {
|
export interface CollapsibleGroups<T> {
|
||||||
/** Filters a section's rows down to the ones visible under the current state. */
|
/** Filters a section's rows down to the ones visible under the current state. */
|
||||||
visible: (rows: T[]) => T[];
|
visible: (rows: T[]) => T[];
|
||||||
/** True when this top-level parent row is collapsed (its children are hidden). */
|
/** True when this parent row is collapsed (its subtree is hidden). */
|
||||||
isCollapsed: (row: T) => boolean;
|
isCollapsed: (row: T) => boolean;
|
||||||
/** Flip one group between collapsed and expanded, then persist. */
|
/** Flip one group between collapsed and expanded, then persist. */
|
||||||
toggle: (row: T) => void;
|
toggle: (row: T) => void;
|
||||||
/** Expand every collapsible group found in `rows`, then persist. */
|
/** Expand every collapsible group found in `rows`, then persist. */
|
||||||
expandAll: (rows: T[]) => void;
|
expandAll: (rows: T[]) => void;
|
||||||
/** Collapse every group (back to the default), then persist. */
|
/** Collapse every collapsible group found in `rows`, then persist. */
|
||||||
collapseAll: () => void;
|
collapseAll: (rows: T[]) => void;
|
||||||
/** True when `rows` has at least one group and all of them are expanded. */
|
/** True when `rows` has at least one group and all of them are expanded. */
|
||||||
allExpanded: (rows: T[]) => boolean;
|
allExpanded: (rows: T[]) => boolean;
|
||||||
/** Number of collapsible top-level groups in `rows`. */
|
/** Number of collapsible groups (parents, any depth) in `rows`. */
|
||||||
groupCount: (rows: T[]) => number;
|
groupCount: (rows: T[]) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-report collapse/expand state for the hierarchical comparable tables
|
* Per-report collapse/expand state for the hierarchical tables (issue #254/#265),
|
||||||
* (issue #254). Groups are collapsed by default (issue #260): the persisted
|
* generalised to every hierarchy level (issue #288).
|
||||||
* value is the set of *expanded* group keys, so a first-ever visit (empty set)
|
|
||||||
* shows every group collapsed with only its subtotal, while any group the user
|
|
||||||
* expands is remembered.
|
|
||||||
*
|
*
|
||||||
* Persistence uses localStorage, the same synchronous store the sibling
|
* The persisted value is the set of keys whose state DIFFERS from the default
|
||||||
* "subtotals on top/bottom" toggle already uses in these tables. State is kept
|
* (see `isCollapsedFor`), so `defaultExpanded: false` + an empty set = everything
|
||||||
* per report via a distinct `storageKey`.
|
* collapsed — the reports' established behaviour — while `defaultExpanded: true`
|
||||||
|
* + an empty set = everything expanded.
|
||||||
|
*
|
||||||
|
* Persistence lives in `user_preferences`, the profile's own SQLite table, so the
|
||||||
|
* state is scoped per profile for free and is destroyed with the profile (no
|
||||||
|
* localStorage residue surviving `deleteProfile` — a privacy-first requirement).
|
||||||
|
* Reads/writes are async; hydration runs in an effect. There is no visible flash
|
||||||
|
* because the default state IS what renders first, and hydration can only reveal
|
||||||
|
* what the user had opened. `storageKey: null` disables persistence entirely
|
||||||
|
* (pure in-memory state, for the category trees).
|
||||||
|
*
|
||||||
|
* The hook stays context-free — it never calls `useProfile()` — because the
|
||||||
|
* `user_preferences` key is already per-profile (it lives in the profile's DB).
|
||||||
*/
|
*/
|
||||||
export function useCollapsibleGroups<T>(
|
export function useCollapsibleGroups<T>(
|
||||||
storageKey: string,
|
storageKey: string | null,
|
||||||
acc: CollapseAccessors<T>,
|
acc: CollapseAccessors<T>,
|
||||||
|
options?: { defaultExpanded?: boolean },
|
||||||
): CollapsibleGroups<T> {
|
): CollapsibleGroups<T> {
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() =>
|
const defaultExpanded = options?.defaultExpanded ?? false;
|
||||||
parseStoredExpanded(
|
|
||||||
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
|
// Keys whose state differs from the default. Initial = default (empty), then an
|
||||||
),
|
// effect hydrates from user_preferences when a storageKey is set.
|
||||||
);
|
const [flipped, setFlipped] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (storageKey === null) {
|
||||||
|
// No persistence: reset to the default state on (re)mount / key change.
|
||||||
|
setFlipped(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void getPreference(storageKey)
|
||||||
|
.then((raw) => {
|
||||||
|
if (!cancelled) setFlipped(parseStoredExpanded(raw));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Best-effort: a read failure just keeps the default state.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
const persist = useCallback(
|
const persist = useCallback(
|
||||||
(next: Set<string>) => {
|
(next: Set<string>) => {
|
||||||
try {
|
setFlipped(next);
|
||||||
localStorage.setItem(storageKey, serializeExpanded(next));
|
if (storageKey !== null) {
|
||||||
} catch {
|
void setPreference(storageKey, serializeExpanded(next)).catch(() => {
|
||||||
// Best-effort: a write failure just means the next session falls back
|
// Best-effort: a write failure just means the next session falls back
|
||||||
// to the collapsed default.
|
// to the default state.
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setExpanded(next);
|
|
||||||
},
|
},
|
||||||
[storageKey],
|
[storageKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]);
|
const isCollapsed = useCallback(
|
||||||
|
(row: T) => isCollapsedFor(flipped, acc.keyOf(row), defaultExpanded),
|
||||||
|
[flipped, acc, defaultExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]);
|
const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]);
|
||||||
|
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
(row: T) => {
|
(row: T) => {
|
||||||
const key = acc.keyOf(row);
|
const key = acc.keyOf(row);
|
||||||
const next = new Set(expanded);
|
const next = new Set(flipped);
|
||||||
if (next.has(key)) next.delete(key);
|
if (next.has(key)) next.delete(key);
|
||||||
else next.add(key);
|
else next.add(key);
|
||||||
persist(next);
|
persist(next);
|
||||||
},
|
},
|
||||||
[expanded, acc, persist],
|
[flipped, acc, persist],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// In `defaultExpanded` polarity the flipped set holds the *collapsed* keys, so
|
||||||
|
// expanding everything means clearing it; collapsing everything means filling
|
||||||
|
// it. The polarity is inverted when the default is collapsed.
|
||||||
const expandAll = useCallback(
|
const expandAll = useCallback(
|
||||||
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
|
(rows: T[]) => persist(defaultExpanded ? new Set() : new Set(collapsibleKeys(rows, acc))),
|
||||||
[acc, persist],
|
[acc, persist, defaultExpanded],
|
||||||
);
|
);
|
||||||
|
|
||||||
const collapseAll = useCallback(() => persist(new Set()), [persist]);
|
const collapseAll = useCallback(
|
||||||
|
(rows: T[]) => persist(defaultExpanded ? new Set(collapsibleKeys(rows, acc)) : new Set()),
|
||||||
|
[acc, persist, defaultExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]);
|
const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]);
|
||||||
|
|
||||||
const allExpanded = useCallback(
|
const allExpanded = useCallback(
|
||||||
(rows: T[]) => {
|
(rows: T[]) => {
|
||||||
const keys = collapsibleKeys(rows, acc);
|
const keys = collapsibleKeys(rows, acc);
|
||||||
return keys.length > 0 && keys.every((k) => expanded.has(k));
|
return keys.length > 0 && keys.every((k) => !isCollapsedFor(flipped, k, defaultExpanded));
|
||||||
},
|
},
|
||||||
[expanded, acc],
|
[flipped, acc, defaultExpanded],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
|
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,234 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
type CollapseAccessors,
|
type CollapseAccessors,
|
||||||
|
MAX_TREE_DEPTH,
|
||||||
collapsibleKeys,
|
collapsibleKeys,
|
||||||
|
isCollapsedFor,
|
||||||
parseStoredExpanded,
|
parseStoredExpanded,
|
||||||
serializeExpanded,
|
serializeExpanded,
|
||||||
visibleRows,
|
visibleRows,
|
||||||
} from "./collapsibleRows";
|
} from "./collapsibleRows";
|
||||||
|
import { reorderRows } from "./reorderRows";
|
||||||
|
|
||||||
/** Tiny hierarchy row for the tests: key, depth, is-parent. */
|
/** Tiny hierarchy row: id, parent id, is-parent flag, indentation depth. */
|
||||||
interface Row {
|
interface Row {
|
||||||
k: string;
|
id: number;
|
||||||
d: number;
|
parent: number | null;
|
||||||
p: boolean;
|
is_parent: boolean;
|
||||||
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const acc: CollapseAccessors<Row> = {
|
const acc: CollapseAccessors<Row> = {
|
||||||
keyOf: (r) => r.k,
|
keyOf: (r) => `p:${r.id}`,
|
||||||
depthOf: (r) => r.d,
|
parentKeyOf: (r) => (r.parent === null ? null : `p:${r.parent}`),
|
||||||
isParent: (r) => r.p,
|
isParent: (r) => r.is_parent,
|
||||||
|
depthOf: (r) => r.depth,
|
||||||
};
|
};
|
||||||
|
|
||||||
const row = (k: string, d: number, p: boolean): Row => ({ k, d, p });
|
const r = (id: number, parent: number | null, is_parent: boolean, depth: number): Row => ({
|
||||||
|
id,
|
||||||
|
parent,
|
||||||
|
is_parent,
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
|
||||||
// A two-group tree with one nested sub-group:
|
// A three-level tree (income → expense → transfer shape is irrelevant here; the
|
||||||
// Housing (parent, d0)
|
// point is the depth):
|
||||||
// Rent (leaf, d1)
|
// Housing (parent, id 1)
|
||||||
// Utilities (parent, d1) <- intermediate parent
|
// Rent (leaf, id 11)
|
||||||
// Power (leaf, d2)
|
// Utilities (INTERMEDIATE parent, id 12)
|
||||||
// Salary (parent, d0)
|
// Power (leaf, id 121)
|
||||||
// Paycheck (leaf, d1)
|
// Water (leaf, id 122)
|
||||||
// Misc (leaf, d0) <- top-level leaf, not a group
|
// Salary (parent, id 2)
|
||||||
const tree: Row[] = [
|
// Paycheck (leaf, id 21)
|
||||||
row("housing", 0, true),
|
// Misc (top-level leaf, id 3)
|
||||||
row("rent", 1, false),
|
const dfsTree: Row[] = [
|
||||||
row("utilities", 1, true),
|
r(1, null, true, 0), // Housing
|
||||||
row("power", 2, false),
|
r(11, 1, false, 1), // Rent
|
||||||
row("salary", 0, true),
|
r(12, 1, true, 1), // Utilities (intermediate)
|
||||||
row("paycheck", 1, false),
|
r(121, 12, false, 2), // Power
|
||||||
row("misc", 0, false),
|
r(122, 12, false, 2), // Water
|
||||||
|
r(2, null, true, 0), // Salary
|
||||||
|
r(21, 2, false, 1), // Paycheck
|
||||||
|
r(3, null, false, 0), // Misc
|
||||||
];
|
];
|
||||||
|
|
||||||
// Default = collapsed: a group is collapsed unless its key is in the expanded set.
|
// The EXACT same tree, emitted level-order (BFS) — the order the budget grid
|
||||||
const isCollapsed = (expanded: Set<string>) => (r: Row) => !expanded.has(r.k);
|
// produces. The ancestor-walk must mask identically to the DFS order.
|
||||||
|
const bfsTree: Row[] = [
|
||||||
|
r(1, null, true, 0), // Housing
|
||||||
|
r(2, null, true, 0), // Salary
|
||||||
|
r(3, null, false, 0), // Misc
|
||||||
|
r(11, 1, false, 1), // Rent
|
||||||
|
r(12, 1, true, 1), // Utilities
|
||||||
|
r(21, 2, false, 1), // Paycheck
|
||||||
|
r(121, 12, false, 2), // Power
|
||||||
|
r(122, 12, false, 2), // Water
|
||||||
|
];
|
||||||
|
|
||||||
describe("collapsibleRows.visibleRows", () => {
|
/** Report polarity: a group is collapsed unless its key is in the expanded set. */
|
||||||
it("collapses every group by default (empty expanded set)", () => {
|
const isCollapsed = (expanded: Set<string>) => (row: Row) => !expanded.has(acc.keyOf(row));
|
||||||
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
const ids = (rows: Row[]) => rows.map((row) => row.id);
|
||||||
// Only depth-0 rows survive: the two group headers + the top-level leaf.
|
const idSet = (rows: Row[]) => new Set(ids(rows));
|
||||||
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "misc"]);
|
|
||||||
|
describe("collapsibleRows.visibleRows — ancestor-walk visibility", () => {
|
||||||
|
it("collapses every level by default (empty set) ⇒ only roots survive", () => {
|
||||||
|
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set()));
|
||||||
|
// Housing + Salary (root parents) + Misc (root leaf); everything deeper folds.
|
||||||
|
expect(ids(visible)).toEqual([1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reveals a group's full subtree (incl. nested sub-groups) when expanded", () => {
|
it("expanding a root reveals ONLY its direct children, not its grandchildren", () => {
|
||||||
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing"])));
|
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1"])));
|
||||||
expect(visible.map((r) => r.k)).toEqual([
|
// Rent + Utilities appear; Power/Water (grandchildren) stay folded because the
|
||||||
"housing",
|
// intermediate Utilities is still collapsed.
|
||||||
"rent",
|
expect(ids(visible)).toEqual([1, 11, 12, 2, 3]);
|
||||||
"utilities",
|
expect(visible.some((row) => row.id === 121)).toBe(false);
|
||||||
"power",
|
expect(visible.some((row) => row.id === 122)).toBe(false);
|
||||||
"salary",
|
|
||||||
"misc",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps groups independent — expanding one leaves the others collapsed", () => {
|
it("expanding a root AND its intermediate reveals the leaves", () => {
|
||||||
const visible = visibleRows(tree, acc, isCollapsed(new Set(["salary"])));
|
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1", "p:12"])));
|
||||||
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "paycheck", "misc"]);
|
expect(ids(visible)).toEqual([1, 11, 12, 121, 122, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows everything when all groups are expanded", () => {
|
it("a collapsed ancestor always wins: expanding an intermediate whose root is collapsed reveals nothing", () => {
|
||||||
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"])));
|
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:12"])));
|
||||||
expect(visible).toHaveLength(tree.length);
|
// Housing is collapsed, so Utilities (and thus Power/Water) stay hidden even
|
||||||
|
// though Utilities itself is expanded.
|
||||||
|
expect(ids(visible)).toEqual([1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("always keeps a top-level leaf that has no group", () => {
|
it("keeps groups independent — expanding Salary leaves Housing folded", () => {
|
||||||
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:2"])));
|
||||||
expect(visible.some((r) => r.k === "misc")).toBe(true);
|
expect(ids(visible)).toEqual([1, 2, 21, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows every row once all parents are expanded", () => {
|
||||||
|
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1", "p:12", "p:2"])));
|
||||||
|
expect(visible).toHaveLength(dfsTree.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BFS (level-order) rows mask IDENTICALLY to DFS rows — the test that would have killed v1", () => {
|
||||||
|
const states: Set<string>[] = [
|
||||||
|
new Set(),
|
||||||
|
new Set(["p:1"]),
|
||||||
|
new Set(["p:1", "p:12"]),
|
||||||
|
new Set(["p:12"]),
|
||||||
|
new Set(["p:2"]),
|
||||||
|
new Set(["p:1", "p:12", "p:2"]),
|
||||||
|
];
|
||||||
|
for (const expanded of states) {
|
||||||
|
const dfs = idSet(visibleRows(dfsTree, acc, isCollapsed(expanded)));
|
||||||
|
const bfs = idSet(visibleRows(bfsTree, acc, isCollapsed(expanded)));
|
||||||
|
expect(bfs).toEqual(dfs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masking is unchanged when subtotals are moved to the bottom (reorderRows)", () => {
|
||||||
|
const expanded = new Set(["p:1", "p:12"]);
|
||||||
|
const normal = idSet(visibleRows(dfsTree, acc, isCollapsed(expanded)));
|
||||||
|
// Order-independent: run reorder FIRST (subtotals below their children), then
|
||||||
|
// filter. v1's depth-cursor algorithm broke exactly here.
|
||||||
|
const bottomFirst = reorderRows(dfsTree, false);
|
||||||
|
const afterReorder = idSet(visibleRows(bottomFirst, acc, isCollapsed(expanded)));
|
||||||
|
expect(afterReorder).toEqual(normal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always keeps a top-level leaf that has no parent", () => {
|
||||||
|
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set()));
|
||||||
|
expect(visible.some((row) => row.id === 3)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collapsibleRows.visibleRows — "(direct)" leaf', () => {
|
||||||
|
// A parent that also holds direct transactions: its "(direct)" leaf shares the
|
||||||
|
// parent's category id but is a leaf, and points back at the parent subtotal.
|
||||||
|
const withDirect: Row[] = [
|
||||||
|
r(1, null, true, 0), // Housing subtotal -> key p:1
|
||||||
|
r(1, 1, false, 1), // Housing (direct) -> key p:1, parentKey p:1
|
||||||
|
r(11, 1, false, 1), // Rent
|
||||||
|
];
|
||||||
|
|
||||||
|
it("hides the (direct) leaf when its parent is collapsed", () => {
|
||||||
|
const visible = visibleRows(withDirect, acc, isCollapsed(new Set()));
|
||||||
|
// Only the Housing subtotal (a root) survives.
|
||||||
|
expect(visible).toHaveLength(1);
|
||||||
|
expect(visible[0].is_parent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the (direct) leaf when its parent is expanded", () => {
|
||||||
|
const visible = visibleRows(withDirect, acc, isCollapsed(new Set(["p:1"])));
|
||||||
|
expect(visible).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never treats the (direct) leaf as a collapsible group", () => {
|
||||||
|
// Both the subtotal and the (direct) leaf carry key p:1, but only the parent
|
||||||
|
// subtotal is collapsible — the leaf is not a parent.
|
||||||
|
expect(collapsibleKeys(withDirect, acc)).toEqual(["p:1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collapsibleRows.visibleRows — corrupt / cross-section chains", () => {
|
||||||
|
it("guards a cyclic parent_id with MAX_TREE_DEPTH (no infinite loop)", () => {
|
||||||
|
// A <-> B cycle, with a leaf hanging under A. Neither A nor B is collapsed, so
|
||||||
|
// the walk keeps climbing until the hop guard trips.
|
||||||
|
const cyclic: Row[] = [
|
||||||
|
r(100, 101, true, 0), // A, parent B
|
||||||
|
r(101, 100, true, 1), // B, parent A
|
||||||
|
r(1001, 100, false, 2), // leaf under A
|
||||||
|
];
|
||||||
|
const visible = visibleRows(cyclic, acc, isCollapsed(new Set(["p:100", "p:101"])));
|
||||||
|
// Terminates and keeps the leaf visible (no collapsed ancestor was found).
|
||||||
|
expect(visible.some((row) => row.id === 1001)).toBe(true);
|
||||||
|
// Sanity: the guard is a finite, positive cap.
|
||||||
|
expect(MAX_TREE_DEPTH).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a row whose ancestor is absent from the section (child of another type)", () => {
|
||||||
|
// Orphan points at id 999, which is not in these rows.
|
||||||
|
const orphaned: Row[] = [r(55, 999, false, 1)];
|
||||||
|
const visible = visibleRows(orphaned, acc, isCollapsed(new Set()));
|
||||||
|
// Missing ancestor ⇒ visible (better an orphan shown than a row hidden by a
|
||||||
|
// parent that lives in another section).
|
||||||
|
expect(ids(visible)).toEqual([55]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("collapsibleRows.collapsibleKeys", () => {
|
describe("collapsibleRows.collapsibleKeys", () => {
|
||||||
it("lists only top-level parent rows, in order", () => {
|
it("lists EVERY parent, intermediates included, in encounter order", () => {
|
||||||
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]);
|
// Housing (p:1), Utilities (p:12, intermediate), Salary (p:2) — NOT Misc/leaves.
|
||||||
|
expect(collapsibleKeys(dfsTree, acc)).toEqual(["p:1", "p:12", "p:2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns [] when there is no hierarchy", () => {
|
it("returns [] when there is no hierarchy", () => {
|
||||||
const flat = [row("a", 0, false), row("b", 0, false)];
|
const flat = [r(1, null, false, 0), r(2, null, false, 0)];
|
||||||
expect(collapsibleKeys(flat, acc)).toEqual([]);
|
expect(collapsibleKeys(flat, acc)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("collapsibleRows.isCollapsedFor — polarity", () => {
|
||||||
|
it("defaultExpanded=false: empty set ⇒ collapsed, a flipped key ⇒ expanded", () => {
|
||||||
|
expect(isCollapsedFor(new Set(), "p:1", false)).toBe(true);
|
||||||
|
expect(isCollapsedFor(new Set(["p:1"]), "p:1", false)).toBe(false);
|
||||||
|
expect(isCollapsedFor(new Set(["p:2"]), "p:1", false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaultExpanded=true: empty set ⇒ expanded, a flipped key ⇒ collapsed", () => {
|
||||||
|
expect(isCollapsedFor(new Set(), "p:1", true)).toBe(false);
|
||||||
|
expect(isCollapsedFor(new Set(["p:1"]), "p:1", true)).toBe(true);
|
||||||
|
expect(isCollapsedFor(new Set(["p:2"]), "p:1", true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
|
describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
|
||||||
it("treats absent storage as the collapsed default (empty set)", () => {
|
it("treats absent storage as the default state (empty set)", () => {
|
||||||
expect(parseStoredExpanded(null).size).toBe(0);
|
expect(parseStoredExpanded(null).size).toBe(0);
|
||||||
expect(parseStoredExpanded("").size).toBe(0);
|
expect(parseStoredExpanded("").size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses a JSON string array back into a set", () => {
|
it("parses a JSON string array back into a set", () => {
|
||||||
expect([...parseStoredExpanded('["housing","salary"]')].sort()).toEqual(["housing", "salary"]);
|
expect([...parseStoredExpanded('["p:1","p:2"]')].sort()).toEqual(["p:1", "p:2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to empty on corrupt or non-array JSON", () => {
|
it("falls back to empty on corrupt or non-array JSON", () => {
|
||||||
|
|
@ -110,7 +242,7 @@ describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips through serialize", () => {
|
it("round-trips through serialize", () => {
|
||||||
const set = new Set(["a", "b"]);
|
const set = new Set(["p:1", "p:12"]);
|
||||||
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]);
|
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["p:1", "p:12"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,104 @@
|
||||||
/**
|
/**
|
||||||
* Collapse/expand of top-level category groups in the hierarchical comparable
|
* Collapse/expand of category groups in the hierarchical reports (real-vs-real
|
||||||
* reports (real-vs-real Compare + Budget-vs-Actual) — issue #254.
|
* Compare, Budget-vs-Actual, trends over time) — issue #254/#265, generalised to
|
||||||
|
* every hierarchy level in issue #288.
|
||||||
*
|
*
|
||||||
* Pure helpers only; the React state + persistence glue lives in the
|
* Pure helpers only; the React state + persistence glue lives in the
|
||||||
* `useCollapsibleGroups` hook.
|
* `useCollapsibleGroups` hook.
|
||||||
*
|
*
|
||||||
* A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps
|
* Visibility is decided by an ANCESTOR WALK, not by row adjacency: a row is
|
||||||
* its own subtotal row visible but hides its entire subtree (every following
|
* hidden iff *any* of its ancestors is collapsed. We climb `parentKeyOf` from a
|
||||||
* depth ≥ 1 row until the next depth-0 row). Rows must arrive in depth-first
|
* row until we reach a root (null), an ancestor outside this section (⇒ visible),
|
||||||
* order — the order the compare/budget services already emit them in, and the
|
* or a collapsed ancestor (⇒ hidden). This is independent of the order rows
|
||||||
* same order `reorderRows` relies on.
|
* arrive in — the fix for the v1 adjacency algorithm, which assumed a
|
||||||
|
* depth-first order the budget grid (level-ordered) does not emit.
|
||||||
*
|
*
|
||||||
* The accessors are passed in rather than read off fixed field names so each
|
* The accessors are supplied by each consumer so every table can key + climb the
|
||||||
* table can supply the *exact* depth expression it renders with (CategoryDelta
|
* exact hierarchy it renders. Keys are prefixed `p:` so they are injective: a
|
||||||
* uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from
|
* parent subtotal row and its own "(direct)" leaf share a category id but the
|
||||||
* `parent_id`). Keeping the collapse depth and the indentation depth identical
|
* leaf is never a parent, so only the subtotal is indexed — the leaf simply
|
||||||
* guarantees the hidden rows are exactly the indented descendants.
|
* carries the parent's key via `parentKeyOf` and is hidden with it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle guard for the ancestor walk. The standard taxonomy is three levels deep;
|
||||||
|
* this generous finite cap only exists so a corrupt cyclic `parent_id` (DB
|
||||||
|
* corruption) can never loop forever. Any real chain terminates in a few hops.
|
||||||
|
*/
|
||||||
|
export const MAX_TREE_DEPTH = 64;
|
||||||
|
|
||||||
/** How the collapse logic reads hierarchy position + identity off a row. */
|
/** How the collapse logic reads hierarchy position + identity off a row. */
|
||||||
export interface CollapseAccessors<T> {
|
export interface CollapseAccessors<T> {
|
||||||
/** Stable per-group key (a category id, stringified). */
|
/** Stable per-parent key, injective across the section: `p:<categoryId>`. */
|
||||||
keyOf: (row: T) => string;
|
keyOf: (row: T) => string;
|
||||||
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */
|
/** Key of this row's parent (`p:<parent_id>`), or null at a root. */
|
||||||
depthOf: (row: T) => number;
|
parentKeyOf: (row: T) => string | null;
|
||||||
/** True when the row is a group header / subtotal (has children). */
|
/** True when the row is a group header / subtotal (has children). */
|
||||||
isParent: (row: T) => boolean;
|
isParent: (row: T) => boolean;
|
||||||
|
/** Indentation depth; 0 = top-level. Drives indentation + aria-level ONLY. */
|
||||||
|
depthOf: (row: T) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps every row that is currently visible given which top-level groups are
|
* Keeps every row that is currently visible given which groups are collapsed. A
|
||||||
* collapsed. `isCollapsed` is consulted only for top-level parent rows; a
|
* row is dropped iff any ancestor (walking `parentKeyOf`) is collapsed; a missing
|
||||||
* collapsed one keeps its own (subtotal) row and drops its whole subtree.
|
* ancestor (parent lives in another section) leaves the row visible.
|
||||||
*/
|
*/
|
||||||
export function visibleRows<T>(
|
export function visibleRows<T>(
|
||||||
rows: T[],
|
rows: T[],
|
||||||
acc: CollapseAccessors<T>,
|
acc: CollapseAccessors<T>,
|
||||||
isCollapsed: (row: T) => boolean,
|
isCollapsed: (row: T) => boolean,
|
||||||
): T[] {
|
): T[] {
|
||||||
const out: T[] = [];
|
// Index ONLY parents: a "(direct)" leaf shares its parent's category id but is
|
||||||
// While true we are inside a collapsed top-level parent's subtree and drop
|
// never a parent itself, so it never clobbers the subtotal it points at.
|
||||||
// every deeper row until the next depth-0 row re-opens the flow.
|
const parents = new Map<string, T>();
|
||||||
let hidingSubtree = false;
|
for (const r of rows) if (acc.isParent(r)) parents.set(acc.keyOf(r), r);
|
||||||
for (const row of rows) {
|
|
||||||
if (acc.depthOf(row) === 0) {
|
const hiddenByAncestor = (row: T): boolean => {
|
||||||
out.push(row);
|
let key = acc.parentKeyOf(row);
|
||||||
hidingSubtree = acc.isParent(row) && isCollapsed(row);
|
let hops = 0;
|
||||||
} else if (!hidingSubtree) {
|
while (key !== null && hops++ < MAX_TREE_DEPTH) {
|
||||||
out.push(row);
|
const parent = parents.get(key);
|
||||||
|
if (parent === undefined) return false; // ancestor outside this section → visible
|
||||||
|
if (isCollapsed(parent)) return true;
|
||||||
|
key = acc.parentKeyOf(parent);
|
||||||
}
|
}
|
||||||
}
|
return false;
|
||||||
return out;
|
};
|
||||||
|
|
||||||
|
return rows.filter((r) => !hiddenByAncestor(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keys of every collapsible group (top-level parent rows), in encounter order. */
|
/** Keys of every collapsible group (all parent rows, any depth), in encounter order. */
|
||||||
export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] {
|
export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] {
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (acc.depthOf(row) === 0 && acc.isParent(row)) keys.push(acc.keyOf(row));
|
if (acc.isParent(row)) keys.push(acc.keyOf(row));
|
||||||
}
|
}
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the persisted value into the set of *expanded* group keys. The report
|
* Polarity of the collapse state. The persisted Set holds the keys whose state
|
||||||
* default is "everything collapsed" (issue #254/#260), so an absent or corrupt
|
* DIFFERS from the default, which lets one hook serve both polarities without a
|
||||||
* value yields an empty set — i.e. all groups collapsed on a first-ever visit.
|
* seed:
|
||||||
|
* - `defaultExpanded: false` (reports, budget) ⇒ Set = the *expanded* keys, so
|
||||||
|
* an empty Set means everything collapsed (the reports' current behaviour).
|
||||||
|
* - `defaultExpanded: true` (category trees) ⇒ Set = the *collapsed* keys, so an
|
||||||
|
* empty Set means everything expanded.
|
||||||
|
*/
|
||||||
|
export function isCollapsedFor(
|
||||||
|
flipped: Set<string>,
|
||||||
|
key: string,
|
||||||
|
defaultExpanded: boolean,
|
||||||
|
): boolean {
|
||||||
|
const isFlipped = flipped.has(key);
|
||||||
|
return defaultExpanded ? isFlipped : !isFlipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the persisted value into the flipped-key set (see `isCollapsedFor`). An
|
||||||
|
* absent or corrupt value yields an empty set — i.e. the default collapse state.
|
||||||
*/
|
*/
|
||||||
export function parseStoredExpanded(raw: string | null): Set<string> {
|
export function parseStoredExpanded(raw: string | null): Set<string> {
|
||||||
if (!raw) return new Set();
|
if (!raw) return new Set();
|
||||||
|
|
@ -75,12 +108,12 @@ export function parseStoredExpanded(raw: string | null): Set<string> {
|
||||||
return new Set(parsed.filter((k): k is string => typeof k === "string"));
|
return new Set(parsed.filter((k): k is string => typeof k === "string"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Corrupt value: fall back to the collapsed default.
|
// Corrupt value: fall back to the default state.
|
||||||
}
|
}
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialises the set of expanded group keys for persistence. */
|
/** Serialises the flipped-key set for persistence. */
|
||||||
export function serializeExpanded(expanded: Set<string>): string {
|
export function serializeExpanded(flipped: Set<string>): string {
|
||||||
return JSON.stringify([...expanded]);
|
return JSON.stringify([...flipped]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue