Compare commits
No commits in common. "issue-289-budget-collapse" and "main" have entirely different histories.
issue-289-
...
main
15 changed files with 545 additions and 942 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
name: release
|
name: release
|
||||||
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
|
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
updated: 2026-07-13
|
updated: 2026-07-01
|
||||||
---
|
---
|
||||||
|
|
||||||
# /release — Release Simpl-Resultat
|
# /release — Release Simpl-Resultat
|
||||||
|
|
@ -15,7 +15,6 @@ updated: 2026-07-13
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
0. **Pré-vol — revalider le tip localement.** `check.yml` ne tourne pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
|
|
||||||
1. Determiner la nouvelle version (argument utilisateur ou demander)
|
1. Determiner la nouvelle version (argument utilisateur ou demander)
|
||||||
2. Bump version dans les 5 fichiers :
|
2. Bump version dans les 5 fichiers :
|
||||||
- `src-tauri/Cargo.toml` (ligne `version = "..."`)
|
- `src-tauri/Cargo.toml` (ligne `version = "..."`)
|
||||||
|
|
@ -38,9 +37,6 @@ updated: 2026-07-13
|
||||||
```
|
```
|
||||||
7. Push : `git push origin main && git push origin vX.Y.Z`
|
7. Push : `git push origin main && git push origin vX.Y.Z`
|
||||||
8. Forgejo CI build automatique (Windows + Linux) via `release.yml` sur `on: push: tags: v*`
|
8. Forgejo CI build automatique (Windows + Linux) via `release.yml` sur `on: push: tags: v*`
|
||||||
9. **Post-CI — vérifier la release publiée.** Surveiller `release.yml` (outil `Monitor` sur le run), puis vérifier la release réellement attachée — `status=success` du workflow ne suffit pas :
|
|
||||||
- Les **7 artefacts** attendus : `.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, et `latest.json`.
|
|
||||||
- Le contenu de `latest.json` (il pilote l'auto-update des installations existantes) : champ `version` correct, signatures non vides pour les deux plateformes, URLs pointant vers les bons binaires, notes extraites du CHANGELOG.
|
|
||||||
|
|
||||||
## Regles
|
## Regles
|
||||||
|
|
||||||
|
|
@ -49,9 +45,7 @@ updated: 2026-07-13
|
||||||
- Format Keep a Changelog : `## [X.Y.Z] - YYYY-MM-DD`
|
- Format Keep a Changelog : `## [X.Y.Z] - YYYY-MM-DD`
|
||||||
- Les changelogs sont bundles dans `public/` pour l'affichage in-app
|
- Les changelogs sont bundles dans `public/` pour l'affichage in-app
|
||||||
- Tag **annote** (`-a`), pas lightweight : les artefacts CI reference le tag pour les release notes
|
- Tag **annote** (`-a`), pas lightweight : les artefacts CI reference le tag pour les release notes
|
||||||
- **Tagger publie vers l'extérieur** : `release.yml` pousse le JSON d'updater, donc les utilisateurs installés reçoivent la mise à jour automatiquement. Confirmer avec Max avant de tagger.
|
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)
|
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)
|
||||||
- 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98.
|
- 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98.
|
||||||
- 2026-07-13 — Étape 0 (pré-vol) : revalider le tip localement avant de tagger — `check.yml` ne tourne pas sur `main`, le tip mergé n'a jamais été vu par le CI ; vérifier `.claude/worktrees/` vide (vitest récurse sinon). Étape 9 (post-CI) : vérifier la release publiée — 7 artefacts attendus + contenu de `latest.json` (pilote l'auto-update) ; `status=success` ne suffit pas. Règle : tagger publie vers l'extérieur (updater automatique) → confirmer avec Max avant de tagger. Source : session fdda84cb (release v0.13.0).
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
|
|
||||||
## [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).
|
|
||||||
- Budget : la grille budget se **replie et se déplie elle aussi à chaque niveau de catégorie**, à l'image des rapports. Chaque catégorie parente porte un chevron, la grille **s'ouvre entièrement repliée** (seules les catégories de premier niveau visibles) pour un aperçu compact, et « Tout déplier » ouvre tous les niveaux en un clic. Replier une catégorie est purement visuel — les totaux de section et les lignes de résultat sont inchangés, et aucune valeur de budget que vous avez saisie n'est jamais perdue. Vos choix de repli sont enregistrés par profil (#289).
|
|
||||||
|
|
||||||
## [0.13.0] - 2026-07-12
|
## [0.13.0] - 2026-07-12
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
|
|
||||||
## [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).
|
|
||||||
- Budget: the budget grid now **collapses and expands at every category level** too, matching the reports. Each parent category carries a chevron, the grid **opens fully collapsed** (only the top-level categories visible) for a compact overview, and "Expand all" opens every level in one click. Folding a category is purely visual — section totals and the result lines are unchanged, and no budget figure you have typed is ever lost. Your collapse choices are saved per profile (#289).
|
|
||||||
|
|
||||||
## [0.13.0] - 2026-07-12
|
## [0.13.0] - 2026-07-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { useState, useRef, useEffect, Fragment } from "react";
|
import { useState, useRef, useEffect, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
|
||||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
|
||||||
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
|
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", {
|
const fmt = new Intl.NumberFormat("en-CA", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -20,22 +18,7 @@ const MONTH_KEYS = [
|
||||||
"months.sep", "months.oct", "months.nov", "months.dec",
|
"months.sep", "months.oct", "months.nov", "months.dec",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Prefixed so the "subtotals on top/bottom" preference is INDEPENDENT of the
|
const STORAGE_KEY = "subtotals-position";
|
||||||
// real-vs-budget report's (BudgetVsActualTable still uses "subtotals-position") —
|
|
||||||
// they collided before (issue #289).
|
|
||||||
const STORAGE_KEY = "budget-subtotals-position";
|
|
||||||
|
|
||||||
// Collapse groups keyed by category id; a row is hidden when any ancestor
|
|
||||||
// (walking parent_id) is collapsed, so every parent level is collapsible.
|
|
||||||
// Declared inline, mirroring ComparePeriodTable / BudgetVsActualTable (issue #289).
|
|
||||||
const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetYearRow> = {
|
|
||||||
keyOf: (row) => `p:${row.category_id}`,
|
|
||||||
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
|
|
||||||
depthOf: (row) => row.depth ?? 0,
|
|
||||||
isParent: (row) => row.is_parent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUDGET_EXPANDED_KEY = "budget-grid-expanded";
|
|
||||||
|
|
||||||
interface BudgetTableProps {
|
interface BudgetTableProps {
|
||||||
rows: BudgetYearRow[];
|
rows: BudgetYearRow[];
|
||||||
|
|
@ -60,15 +43,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collapse/expand at every hierarchy level — collapsed by default (issue #289),
|
|
||||||
// matching the hierarchical reports. Persisted per profile in user_preferences.
|
|
||||||
const groups = useCollapsibleGroups<BudgetYearRow>(
|
|
||||||
BUDGET_EXPANDED_KEY,
|
|
||||||
BUDGET_COLLAPSE_ACCESSORS,
|
|
||||||
{ defaultExpanded: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const annualInputRef = useRef<HTMLInputElement>(null);
|
const annualInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -199,37 +173,24 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
|
const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`;
|
||||||
|
|
||||||
if (row.is_parent) {
|
if (row.is_parent) {
|
||||||
// Parent subtotal row: read-only, bold, distinct background. Collapsible
|
// Parent subtotal row: read-only, bold, distinct background
|
||||||
// at every level (issue #289) — a chevron toggles its subtree.
|
|
||||||
const parentDepth = row.depth ?? 0;
|
const parentDepth = row.depth ?? 0;
|
||||||
const isTopParent = parentDepth === 0;
|
const isTopParent = parentDepth === 0;
|
||||||
const isIntermediateParent = parentDepth >= 1;
|
const isIntermediateParent = parentDepth >= 1;
|
||||||
const collapsed = groups.isCollapsed(row);
|
|
||||||
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
|
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey}
|
key={rowKey}
|
||||||
aria-level={parentDepth + 1}
|
|
||||||
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
|
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
|
||||||
>
|
>
|
||||||
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
|
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
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
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: row.category_color }}
|
style={{ backgroundColor: row.category_color }}
|
||||||
/>
|
/>
|
||||||
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
|
||||||
</button>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
|
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
|
||||||
{formatSigned(row.previousYearTotal)}
|
{formatSigned(row.previousYearTotal)}
|
||||||
|
|
@ -250,7 +211,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey}
|
key={rowKey}
|
||||||
aria-level={depth + 1}
|
|
||||||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Category name - sticky */}
|
{/* Category name - sticky */}
|
||||||
|
|
@ -337,11 +297,18 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
|
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
|
||||||
const group = grouped[type];
|
const group = grouped[type];
|
||||||
if (!group || group.length === 0) return null;
|
if (!group || group.length === 0) return null;
|
||||||
// Section subtotal is summed from the RAW group via the tested
|
const sign = signFor(type);
|
||||||
// `sumLeavesForType` (leaves only, sign applied to budgeted figures), never
|
const leaves = group.filter((r) => !r.is_parent);
|
||||||
// from the collapse-filtered rows — folding a parent stays purely visual and
|
const sectionMonthTotals: number[] = Array(12).fill(0);
|
||||||
// never moves a total (issue #289).
|
let sectionAnnualTotal = 0;
|
||||||
const sectionTotals = sumLeavesForType(group, type);
|
let sectionPrevYearTotal = 0;
|
||||||
|
for (const row of leaves) {
|
||||||
|
for (let m = 0; m < 12; m++) {
|
||||||
|
sectionMonthTotals[m] += row.months[m] * sign;
|
||||||
|
}
|
||||||
|
sectionAnnualTotal += row.annual * sign;
|
||||||
|
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Fragment key={type}>
|
<Fragment key={type}>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -352,14 +319,14 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
{t(typeLabelKeys[type])}
|
{t(typeLabelKeys[type])}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))}
|
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
|
||||||
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
||||||
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
||||||
{t(typeTotalKeys[type])}
|
{t(typeTotalKeys[type])}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionTotals.previousYearTotal)}</td>
|
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
|
||||||
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionTotals.annual)}</td>
|
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
|
||||||
{sectionTotals.months.map((total, mIdx) => (
|
{sectionMonthTotals.map((total, mIdx) => (
|
||||||
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
|
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
|
||||||
{formatSigned(total)}
|
{formatSigned(total)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -395,22 +362,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasGroups = groups.groupCount(rows) > 0;
|
|
||||||
const allExpanded = groups.allExpanded(rows);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||||
{hasGroups && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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} />}
|
|
||||||
{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"
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ interface BudgetVsActualTableProps {
|
||||||
|
|
||||||
const STORAGE_KEY = "subtotals-position";
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
// Collapse groups keyed by category id; a row is hidden when any ancestor
|
// Collapse groups keyed by category id; depth mirrors the render logic below
|
||||||
// (walking parent_id) is collapsed, so every parent level is collapsible.
|
// (a missing depth is derived from parent_id) so hidden rows are exactly a
|
||||||
|
// group's indented descendants.
|
||||||
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
|
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
|
||||||
keyOf: (row) => `p:${row.category_id}`,
|
keyOf: (row) => String(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,12 +116,11 @@ 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 = isParent && groups.isCollapsed(row);
|
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
|
||||||
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" : ""
|
||||||
|
|
@ -134,7 +133,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)]`
|
||||||
}`}>
|
}`}>
|
||||||
{isParent ? (
|
{isTopParent ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => groups.toggle(row)}
|
onClick={() => groups.toggle(row)}
|
||||||
|
|
@ -256,7 +255,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
{hasGroups && (
|
{hasGroups && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (allExpanded ? groups.collapseAll(data) : groups.expandAll(data))}
|
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"
|
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,13 +93,12 @@ 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 = isParent && groups.isCollapsed(row);
|
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 (
|
||||||
<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"
|
||||||
|
|
@ -117,7 +116,7 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isParent ? (
|
{isTopParent ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => groups.toggle(row)}
|
onClick={() => groups.toggle(row)}
|
||||||
|
|
@ -212,7 +211,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(data.tree) : groups.expandAll(data.tree))}
|
onClick={() => (allExpanded ? groups.collapseAll() : 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} />}
|
||||||
|
|
|
||||||
74
src/components/reports/CategoryTable.tsx
Normal file
74
src/components/reports/CategoryTable.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
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,11 +59,10 @@ 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; a row is hidden when any ancestor
|
// Collapse groups keyed by category id; depth/parent mirror the render logic
|
||||||
// (walking parent_id) is collapsed, so every parent level is collapsible.
|
// below so the hidden rows are exactly a group's indented descendants.
|
||||||
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
|
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
|
||||||
keyOf: (row) => `p:${row.categoryId}`,
|
keyOf: (row) => String(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,
|
||||||
};
|
};
|
||||||
|
|
@ -155,13 +154,12 @@ 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 = isParent && groups.isCollapsed(row);
|
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 (
|
||||||
<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"
|
||||||
|
|
@ -179,7 +177,7 @@ export default function ComparePeriodTable({
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isParent ? (
|
{isTopParent ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => groups.toggle(row)}
|
onClick={() => groups.toggle(row)}
|
||||||
|
|
@ -351,7 +349,7 @@ export default function ComparePeriodTable({
|
||||||
{hasGroups && (
|
{hasGroups && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
|
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"
|
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,13 +45,11 @@ 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, three levels deep on the expense
|
// A realistic parent-first depth-first tree (income → expense → transfer):
|
||||||
// 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)
|
||||||
// Alimentation (INTERMEDIATE parent, id 20)
|
|
||||||
// Épicerie (leaf, id 21)
|
// Épicerie (leaf, id 21)
|
||||||
// Resto (leaf, id 22)
|
// Resto (leaf, id 22)
|
||||||
// Loyer (top-level leaf, id 23)
|
// Loyer (top-level leaf, id 23)
|
||||||
|
|
@ -62,9 +60,8 @@ function buildTree(): OverTimeRow[] {
|
||||||
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(20, "Alimentation", [500, 800], "expense", { is_parent: true, parent_id: 2, depth: 1 }),
|
mkRow(21, "Épicerie", [400, 600], "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: 2, depth: 1 }),
|
||||||
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 }),
|
||||||
];
|
];
|
||||||
|
|
@ -73,7 +70,6 @@ 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", () => {
|
||||||
|
|
@ -83,18 +79,13 @@ describe("overTimeTableModel — storage key", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("overTimeTableModel — collapse accessors", () => {
|
describe("overTimeTableModel — collapse accessors", () => {
|
||||||
it("keys parents/leaves injectively (p:<id>) and climbs parent_id", () => {
|
it("keys by category id and reads the snake_case hierarchy block", () => {
|
||||||
const revenus = buildTree()[0]; // parent
|
const parent = buildTree()[0];
|
||||||
const paie = buildTree()[1]; // leaf under Revenus
|
const leaf = buildTree()[1];
|
||||||
const alimentation = buildTree()[4]; // intermediate parent
|
expect(acc.keyOf(parent)).toBe("1");
|
||||||
expect(acc.keyOf(revenus)).toBe("p:1");
|
expect(acc.depthOf(leaf)).toBe(1);
|
||||||
expect(acc.parentKeyOf(revenus)).toBeNull(); // root
|
expect(acc.isParent(parent)).toBe(true);
|
||||||
expect(acc.parentKeyOf(paie)).toBe("p:1"); // climbs to Revenus
|
expect(acc.isParent(leaf)).toBe(false);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -110,14 +101,12 @@ 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(names(income.rows)).toEqual(["Revenus", "Paie", "Bonus"]);
|
expect(income.rows.map((r) => r.categoryName)).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(names(expense.rows)).toEqual([
|
expect(expense.rows.map((r) => r.categoryName)).toEqual([
|
||||||
"Dépenses",
|
"Dépenses",
|
||||||
"Alimentation",
|
|
||||||
"Épicerie",
|
"Épicerie",
|
||||||
"Resto",
|
"Resto",
|
||||||
"Loyer",
|
"Loyer",
|
||||||
|
|
@ -125,7 +114,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 — parents (incl. intermediate) are never double-counted", () => {
|
it("keeps subtotals as the reducer's leaf sums — a parent is 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);
|
||||||
|
|
@ -136,8 +125,7 @@ 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 AND Alimentation
|
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded).
|
||||||
// parents excluded, even though Alimentation carries a [500,800] series).
|
|
||||||
expect(expense.total).toBe(3300);
|
expect(expense.total).toBe(3300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -160,13 +148,12 @@ 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(names(visIncome)).toEqual(["Revenus"]);
|
expect(visIncome.map((r) => r.categoryName)).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 whole subtree (Alimentation + its leaves); the top-level
|
// Dépenses folds its children; the top-level leaf Loyer always stays.
|
||||||
// leaf Loyer always stays.
|
expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]);
|
||||||
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", () => {
|
||||||
|
|
@ -174,55 +161,28 @@ 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(["p:1"])));
|
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
|
||||||
expect(names(visible)).toEqual(["Revenus", "Paie", "Bonus"]);
|
expect(visible.map((r) => r.categoryName)).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(["p:1"])));
|
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
|
||||||
expect(collapsed.length).toBeLessThan(expanded.length);
|
expect(collapsed.length).toBeLessThan(expanded.length);
|
||||||
|
// The subtotal the table shows is identical in both states.
|
||||||
// Expanding the intermediate expense level does not move the section subtotal.
|
expect(income.total).toBe(6500);
|
||||||
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,14 +10,13 @@ import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapse groups keyed by category id; a row is hidden when any ancestor
|
* Collapse groups keyed by category id; depth/parent mirror the tree the render
|
||||||
* (walking parent_id) is collapsed, so every parent level is collapsible. The
|
* indents by, so a collapsed group hides exactly its indented descendants. 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) => `p:${row.categoryId}`,
|
keyOf: (row) => String(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,130 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import type { BudgetYearRow, Category, BudgetEntry } from "../shared/types";
|
|
||||||
import { buildBudgetYearRows } from "./useBudget";
|
|
||||||
import { sumLeavesForType } from "../components/budget/budgetTableResults";
|
|
||||||
import {
|
|
||||||
type CollapseAccessors,
|
|
||||||
visibleRows,
|
|
||||||
isCollapsedFor,
|
|
||||||
} from "../utils/collapsibleRows";
|
|
||||||
|
|
||||||
// The Budget grid's rows are built by `useBudget` (the "3rd builder" the
|
|
||||||
// abandoned v1 collapse plan ignored). Its final sort is LEVEL-ORDER (depth
|
|
||||||
// ascending / BFS), not depth-first. These tests pin that invariant: the
|
|
||||||
// shipped ancestor-walk collapse (issue #288) is order-INDEPENDENT, but the v1
|
|
||||||
// depth-cursor algorithm assumed DFS and would have broken exactly here.
|
|
||||||
|
|
||||||
function cat(
|
|
||||||
id: number,
|
|
||||||
name: string,
|
|
||||||
type: Category["type"],
|
|
||||||
opts: { parent_id?: number; is_inputable?: boolean; sort_order?: number } = {},
|
|
||||||
): Category {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
parent_id: opts.parent_id,
|
|
||||||
color: "#000",
|
|
||||||
is_active: true,
|
|
||||||
is_inputable: opts.is_inputable ?? true,
|
|
||||||
sort_order: opts.sort_order ?? 0,
|
|
||||||
created_at: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// income leaf + a 3-level expense group:
|
|
||||||
// Housing (root, non-inputable)
|
|
||||||
// ├─ Rent (depth-1 leaf)
|
|
||||||
// └─ Utilities (depth-1 intermediate parent)
|
|
||||||
// ├─ Hydro (depth-2 leaf)
|
|
||||||
// └─ Internet (depth-2 leaf)
|
|
||||||
const CATEGORIES: Category[] = [
|
|
||||||
cat(10, "Salary", "income", { sort_order: 0 }),
|
|
||||||
cat(1, "Housing", "expense", { is_inputable: false, sort_order: 1 }),
|
|
||||||
cat(2, "Rent", "expense", { parent_id: 1, sort_order: 0 }),
|
|
||||||
cat(3, "Utilities", "expense", { parent_id: 1, is_inputable: false, sort_order: 1 }),
|
|
||||||
cat(4, "Hydro", "expense", { parent_id: 3, sort_order: 0 }),
|
|
||||||
cat(5, "Internet", "expense", { parent_id: 3, sort_order: 1 }),
|
|
||||||
];
|
|
||||||
|
|
||||||
function entry(category_id: number, amount: number, month = 1): BudgetEntry {
|
|
||||||
return { id: 0, category_id, year: 2026, month, amount, created_at: "", updated_at: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same accessors BudgetTable declares inline — duplicated here (they are 4 trivial
|
|
||||||
// lambdas the issue mandates stay inline; a .tsx import into a node-env test would
|
|
||||||
// pull in JSX/lucide/react-i18next needlessly).
|
|
||||||
const ACCESSORS: CollapseAccessors<BudgetYearRow> = {
|
|
||||||
keyOf: (row) => `p:${row.category_id}`,
|
|
||||||
parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null),
|
|
||||||
depthOf: (row) => row.depth ?? 0,
|
|
||||||
isParent: (row) => row.is_parent,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("buildBudgetYearRows — level-order emission (issue #289 invariant)", () => {
|
|
||||||
const rows = buildBudgetYearRows(CATEGORIES, [], []);
|
|
||||||
|
|
||||||
it("emits income before expense, then each group in LEVEL order (BFS), not DFS", () => {
|
|
||||||
// Salary (income) first; then the Housing expense group depth-ascending:
|
|
||||||
// root subtotal, then the depth-1 rows (parent Utilities before leaf Rent),
|
|
||||||
// then the depth-2 grandchildren.
|
|
||||||
expect(rows.map((r) => r.category_id)).toEqual([10, 1, 3, 2, 4, 5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps depth non-decreasing within a top group — the property that would have killed the v1 (DFS-assuming) collapse algorithm", () => {
|
|
||||||
const expenseGroup = rows.filter((r) => r.category_type === "expense");
|
|
||||||
const depths = expenseGroup.map((r) => r.depth ?? 0);
|
|
||||||
for (let i = 1; i < depths.length; i++) {
|
|
||||||
expect(depths[i]).toBeGreaterThanOrEqual(depths[i - 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit DFS-killer: the depth-2 grandchildren (Hydro/Internet) come AFTER
|
|
||||||
// the depth-1 leaf sibling (Rent). A depth-first emission would interleave
|
|
||||||
// them directly under Utilities, i.e. BEFORE Rent.
|
|
||||||
const leafIdx = (id: number) => rows.findIndex((r) => r.category_id === id && !r.is_parent);
|
|
||||||
expect(leafIdx(4)).toBeGreaterThan(leafIdx(2)); // Hydro after Rent
|
|
||||||
expect(leafIdx(5)).toBeGreaterThan(leafIdx(2)); // Internet after Rent
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {
|
|
||||||
const rows = buildBudgetYearRows(CATEGORIES, [], []);
|
|
||||||
|
|
||||||
it("collapsed-by-default (empty set) shows only the roots", () => {
|
|
||||||
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(new Set<string>(), ACCESSORS.keyOf(r), false);
|
|
||||||
const visible = visibleRows(rows, ACCESSORS, isCollapsed);
|
|
||||||
// Salary (root leaf) + Housing (root subtotal); nothing beneath Housing.
|
|
||||||
expect(visible.map((r) => r.category_id)).toEqual([10, 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expanding a root reveals its DIRECT children only — grandchildren stay folded under the still-collapsed intermediate", () => {
|
|
||||||
const flipped = new Set<string>(["p:1"]); // Housing expanded
|
|
||||||
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(flipped, ACCESSORS.keyOf(r), false);
|
|
||||||
const visible = visibleRows(rows, ACCESSORS, isCollapsed);
|
|
||||||
// Housing's direct children: Utilities subtotal + Rent leaf. Hydro/Internet
|
|
||||||
// remain hidden under the collapsed Utilities.
|
|
||||||
expect(visible.map((r) => r.category_id)).toEqual([10, 1, 3, 2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("BudgetTable section subtotals stay on RAW rows (collapse is purely visual, issue #289)", () => {
|
|
||||||
const ENTRIES = [entry(2, 1000), entry(4, 100), entry(5, 50)];
|
|
||||||
const rows = buildBudgetYearRows(CATEGORIES, ENTRIES, []);
|
|
||||||
|
|
||||||
it("sums every expense LEAF regardless of collapse — folding a parent never moves a total", () => {
|
|
||||||
const rawExpense = sumLeavesForType(rows, "expense");
|
|
||||||
expect(rawExpense.annual).toBe(-1150); // -(Rent 1000 + Hydro 100 + Internet 50)
|
|
||||||
|
|
||||||
// If the grid mistakenly summed the collapse-visible rows, a fully-collapsed
|
|
||||||
// grid (only roots visible, and the expense root is a parent → excluded)
|
|
||||||
// would report 0. The grid feeds the RAW group to `sumLeavesForType`, so the
|
|
||||||
// section total is invariant under collapse; this asserts the two differ,
|
|
||||||
// which is exactly why raw rows must be used.
|
|
||||||
const isCollapsed = (r: BudgetYearRow) => isCollapsedFor(new Set<string>(), ACCESSORS.keyOf(r), false);
|
|
||||||
const collapsedVisible = visibleRows(rows, ACCESSORS, isCollapsed);
|
|
||||||
const visibleExpense = sumLeavesForType(collapsedVisible, "expense");
|
|
||||||
expect(visibleExpense.annual).toBe(0);
|
|
||||||
expect(rawExpense.annual).not.toBe(visibleExpense.annual);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type { BudgetYearRow, BudgetTemplate, ImportSource, Category, BudgetEntry } from "../shared/types";
|
import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
|
||||||
import {
|
import {
|
||||||
getAllActiveCategories,
|
getAllActiveCategories,
|
||||||
getBudgetEntriesForYear,
|
getBudgetEntriesForYear,
|
||||||
|
|
@ -73,30 +73,26 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||||
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
|
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
|
||||||
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||||
|
|
||||||
/**
|
export function useBudget() {
|
||||||
* Assembles the flat, hierarchical `BudgetYearRow[]` the grid renders from the
|
const { accountIds } = useReportsPeriod();
|
||||||
* raw category tree, this year's budget entries, and last year's actual totals.
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||||
*
|
const fetchIdRef = useRef(0);
|
||||||
* ORDERING INVARIANT (issue #289 — the multi-level-collapse consumer). The
|
|
||||||
* final `rows.sort` below orders each top category group by DEPTH ASCENDING
|
const refreshData = useCallback(async (year: number, ids: number[]) => {
|
||||||
* (level-order / BFS), NOT depth-first: within a group every depth-0 row
|
const fetchId = ++fetchIdRef.current;
|
||||||
* precedes every depth-1 row, which precedes every depth-2 row. The abandoned
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
* v1 collapse algorithm wrongly assumed a depth-first emission order and broke
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
* exactly here; the shipped algorithm (ancestor walk — see `collapsibleRows.ts`)
|
|
||||||
* is order-INDEPENDENT, so this sort needs NO change. The level-order property
|
try {
|
||||||
* is pinned by `useBudget.test.ts` so a refactor can't silently reintroduce the
|
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
||||||
* v1 assumption — do not reorder into DFS without revisiting the collapse
|
getAllActiveCategories(),
|
||||||
* consumer (`BudgetTable`).
|
getBudgetEntriesForYear(year),
|
||||||
*
|
getActualTotalsForYear(year - 1, ids),
|
||||||
* Pure (no DB, no React) so the invariant is unit-testable without a renderer —
|
getAllTemplates(),
|
||||||
* the repo has no jsdom, so hook logic that must be tested is extracted here,
|
]);
|
||||||
* the same way `useCompare.ts` exposes its pure helpers.
|
|
||||||
*/
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
export function buildBudgetYearRows(
|
|
||||||
allCategories: Category[],
|
|
||||||
entries: BudgetEntry[],
|
|
||||||
prevYearActuals: Array<{ category_id: number | null; actual: number }>,
|
|
||||||
): BudgetYearRow[] {
|
|
||||||
// Build a map: categoryId -> month(1-12) -> amount
|
// Build a map: categoryId -> month(1-12) -> amount
|
||||||
const entryMap = new Map<number, Map<number, number>>();
|
const entryMap = new Map<number, Map<number, number>>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
|
|
@ -368,31 +364,6 @@ export function buildBudgetYearRows(
|
||||||
return a.category_name.localeCompare(b.category_name);
|
return a.category_name.localeCompare(b.category_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBudget() {
|
|
||||||
const { accountIds } = useReportsPeriod();
|
|
||||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
|
||||||
const fetchIdRef = useRef(0);
|
|
||||||
|
|
||||||
const refreshData = useCallback(async (year: number, ids: number[]) => {
|
|
||||||
const fetchId = ++fetchIdRef.current;
|
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
|
||||||
getAllActiveCategories(),
|
|
||||||
getBudgetEntriesForYear(year),
|
|
||||||
getActualTotalsForYear(year - 1, ids),
|
|
||||||
getAllTemplates(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
|
||||||
|
|
||||||
const rows = buildBudgetYearRows(allCategories, entries, prevYearActuals);
|
|
||||||
|
|
||||||
dispatch({ type: "SET_DATA", payload: { rows, templates } });
|
dispatch({ type: "SET_DATA", payload: { rows, templates } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
|
|
||||||
|
|
@ -1,133 +1,93 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, 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 parent row is collapsed (its subtree is hidden). */
|
/** True when this top-level parent row is collapsed (its children are 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 collapsible group found in `rows`, then persist. */
|
/** Collapse every group (back to the default), then persist. */
|
||||||
collapseAll: (rows: T[]) => void;
|
collapseAll: () => 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 groups (parents, any depth) in `rows`. */
|
/** Number of collapsible top-level groups in `rows`. */
|
||||||
groupCount: (rows: T[]) => number;
|
groupCount: (rows: T[]) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-report collapse/expand state for the hierarchical tables (issue #254/#265),
|
* Per-report collapse/expand state for the hierarchical comparable tables
|
||||||
* generalised to every hierarchy level (issue #288).
|
* (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.
|
||||||
*
|
*
|
||||||
* The persisted value is the set of keys whose state DIFFERS from the default
|
* Persistence uses localStorage, the same synchronous store the sibling
|
||||||
* (see `isCollapsedFor`), so `defaultExpanded: false` + an empty set = everything
|
* "subtotals on top/bottom" toggle already uses in these tables. State is kept
|
||||||
* collapsed — the reports' established behaviour — while `defaultExpanded: true`
|
* per report via a distinct `storageKey`.
|
||||||
* + 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 | null,
|
storageKey: string,
|
||||||
acc: CollapseAccessors<T>,
|
acc: CollapseAccessors<T>,
|
||||||
options?: { defaultExpanded?: boolean },
|
|
||||||
): CollapsibleGroups<T> {
|
): CollapsibleGroups<T> {
|
||||||
const defaultExpanded = options?.defaultExpanded ?? false;
|
const [expanded, setExpanded] = useState<Set<string>>(() =>
|
||||||
|
parseStoredExpanded(
|
||||||
// Keys whose state differs from the default. Initial = default (empty), then an
|
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
|
||||||
// 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>) => {
|
||||||
setFlipped(next);
|
try {
|
||||||
if (storageKey !== null) {
|
localStorage.setItem(storageKey, serializeExpanded(next));
|
||||||
void setPreference(storageKey, serializeExpanded(next)).catch(() => {
|
} 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 default state.
|
// to the collapsed default.
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setExpanded(next);
|
||||||
},
|
},
|
||||||
[storageKey],
|
[storageKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isCollapsed = useCallback(
|
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]);
|
||||||
(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(flipped);
|
const next = new Set(expanded);
|
||||||
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);
|
||||||
},
|
},
|
||||||
[flipped, acc, persist],
|
[expanded, 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(defaultExpanded ? new Set() : new Set(collapsibleKeys(rows, acc))),
|
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
|
||||||
[acc, persist, defaultExpanded],
|
[acc, persist],
|
||||||
);
|
);
|
||||||
|
|
||||||
const collapseAll = useCallback(
|
const collapseAll = useCallback(() => persist(new Set()), [persist]);
|
||||||
(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) => !isCollapsedFor(flipped, k, defaultExpanded));
|
return keys.length > 0 && keys.every((k) => expanded.has(k));
|
||||||
},
|
},
|
||||||
[flipped, acc, defaultExpanded],
|
[expanded, acc],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
|
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
|
||||||
|
|
|
||||||
|
|
@ -1,234 +1,102 @@
|
||||||
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: id, parent id, is-parent flag, indentation depth. */
|
/** Tiny hierarchy row for the tests: key, depth, is-parent. */
|
||||||
interface Row {
|
interface Row {
|
||||||
id: number;
|
k: string;
|
||||||
parent: number | null;
|
d: number;
|
||||||
is_parent: boolean;
|
p: boolean;
|
||||||
depth: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const acc: CollapseAccessors<Row> = {
|
const acc: CollapseAccessors<Row> = {
|
||||||
keyOf: (r) => `p:${r.id}`,
|
keyOf: (r) => r.k,
|
||||||
parentKeyOf: (r) => (r.parent === null ? null : `p:${r.parent}`),
|
depthOf: (r) => r.d,
|
||||||
isParent: (r) => r.is_parent,
|
isParent: (r) => r.p,
|
||||||
depthOf: (r) => r.depth,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const r = (id: number, parent: number | null, is_parent: boolean, depth: number): Row => ({
|
const row = (k: string, d: number, p: boolean): Row => ({ k, d, p });
|
||||||
id,
|
|
||||||
parent,
|
|
||||||
is_parent,
|
|
||||||
depth,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A three-level tree (income → expense → transfer shape is irrelevant here; the
|
// A two-group tree with one nested sub-group:
|
||||||
// point is the depth):
|
// Housing (parent, d0)
|
||||||
// Housing (parent, id 1)
|
// Rent (leaf, d1)
|
||||||
// Rent (leaf, id 11)
|
// Utilities (parent, d1) <- intermediate parent
|
||||||
// Utilities (INTERMEDIATE parent, id 12)
|
// Power (leaf, d2)
|
||||||
// Power (leaf, id 121)
|
// Salary (parent, d0)
|
||||||
// Water (leaf, id 122)
|
// Paycheck (leaf, d1)
|
||||||
// Salary (parent, id 2)
|
// Misc (leaf, d0) <- top-level leaf, not a group
|
||||||
// Paycheck (leaf, id 21)
|
const tree: Row[] = [
|
||||||
// Misc (top-level leaf, id 3)
|
row("housing", 0, true),
|
||||||
const dfsTree: Row[] = [
|
row("rent", 1, false),
|
||||||
r(1, null, true, 0), // Housing
|
row("utilities", 1, true),
|
||||||
r(11, 1, false, 1), // Rent
|
row("power", 2, false),
|
||||||
r(12, 1, true, 1), // Utilities (intermediate)
|
row("salary", 0, true),
|
||||||
r(121, 12, false, 2), // Power
|
row("paycheck", 1, false),
|
||||||
r(122, 12, false, 2), // Water
|
row("misc", 0, false),
|
||||||
r(2, null, true, 0), // Salary
|
|
||||||
r(21, 2, false, 1), // Paycheck
|
|
||||||
r(3, null, false, 0), // Misc
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// The EXACT same tree, emitted level-order (BFS) — the order the budget grid
|
// Default = collapsed: a group is collapsed unless its key is in the expanded set.
|
||||||
// produces. The ancestor-walk must mask identically to the DFS order.
|
const isCollapsed = (expanded: Set<string>) => (r: Row) => !expanded.has(r.k);
|
||||||
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
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Report polarity: a group is collapsed unless its key is in the expanded set. */
|
describe("collapsibleRows.visibleRows", () => {
|
||||||
const isCollapsed = (expanded: Set<string>) => (row: Row) => !expanded.has(acc.keyOf(row));
|
it("collapses every group by default (empty expanded set)", () => {
|
||||||
const ids = (rows: Row[]) => rows.map((row) => row.id);
|
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
||||||
const idSet = (rows: Row[]) => new Set(ids(rows));
|
// Only depth-0 rows survive: the two group headers + the top-level leaf.
|
||||||
|
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("expanding a root reveals ONLY its direct children, not its grandchildren", () => {
|
it("reveals a group's full subtree (incl. nested sub-groups) when expanded", () => {
|
||||||
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1"])));
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing"])));
|
||||||
// Rent + Utilities appear; Power/Water (grandchildren) stay folded because the
|
expect(visible.map((r) => r.k)).toEqual([
|
||||||
// intermediate Utilities is still collapsed.
|
"housing",
|
||||||
expect(ids(visible)).toEqual([1, 11, 12, 2, 3]);
|
"rent",
|
||||||
expect(visible.some((row) => row.id === 121)).toBe(false);
|
"utilities",
|
||||||
expect(visible.some((row) => row.id === 122)).toBe(false);
|
"power",
|
||||||
|
"salary",
|
||||||
|
"misc",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expanding a root AND its intermediate reveals the leaves", () => {
|
it("keeps groups independent — expanding one leaves the others collapsed", () => {
|
||||||
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1", "p:12"])));
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["salary"])));
|
||||||
expect(ids(visible)).toEqual([1, 11, 12, 121, 122, 2, 3]);
|
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "paycheck", "misc"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a collapsed ancestor always wins: expanding an intermediate whose root is collapsed reveals nothing", () => {
|
it("shows everything when all groups are expanded", () => {
|
||||||
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:12"])));
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"])));
|
||||||
// Housing is collapsed, so Utilities (and thus Power/Water) stay hidden even
|
expect(visible).toHaveLength(tree.length);
|
||||||
// though Utilities itself is expanded.
|
|
||||||
expect(ids(visible)).toEqual([1, 2, 3]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps groups independent — expanding Salary leaves Housing folded", () => {
|
it("always keeps a top-level leaf that has no group", () => {
|
||||||
const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:2"])));
|
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
||||||
expect(ids(visible)).toEqual([1, 2, 21, 3]);
|
expect(visible.some((r) => r.k === "misc")).toBe(true);
|
||||||
});
|
|
||||||
|
|
||||||
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 EVERY parent, intermediates included, in encounter order", () => {
|
it("lists only top-level parent rows, in order", () => {
|
||||||
// Housing (p:1), Utilities (p:12, intermediate), Salary (p:2) — NOT Misc/leaves.
|
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]);
|
||||||
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 = [r(1, null, false, 0), r(2, null, false, 0)];
|
const flat = [row("a", 0, false), row("b", 0, false)];
|
||||||
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 default state (empty set)", () => {
|
it("treats absent storage as the collapsed default (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('["p:1","p:2"]')].sort()).toEqual(["p:1", "p:2"]);
|
expect([...parseStoredExpanded('["housing","salary"]')].sort()).toEqual(["housing", "salary"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to empty on corrupt or non-array JSON", () => {
|
it("falls back to empty on corrupt or non-array JSON", () => {
|
||||||
|
|
@ -242,7 +110,7 @@ describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips through serialize", () => {
|
it("round-trips through serialize", () => {
|
||||||
const set = new Set(["p:1", "p:12"]);
|
const set = new Set(["a", "b"]);
|
||||||
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["p:1", "p:12"]);
|
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,71 @@
|
||||||
/**
|
/**
|
||||||
* Collapse/expand of category groups in the hierarchical reports (real-vs-real
|
* Collapse/expand of top-level category groups in the hierarchical comparable
|
||||||
* Compare, Budget-vs-Actual, trends over time) — issue #254/#265, generalised to
|
* reports (real-vs-real Compare + Budget-vs-Actual) — issue #254.
|
||||||
* 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.
|
||||||
*
|
*
|
||||||
* Visibility is decided by an ANCESTOR WALK, not by row adjacency: a row is
|
* A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps
|
||||||
* hidden iff *any* of its ancestors is collapsed. We climb `parentKeyOf` from a
|
* its own subtotal row visible but hides its entire subtree (every following
|
||||||
* row until we reach a root (null), an ancestor outside this section (⇒ visible),
|
* depth ≥ 1 row until the next depth-0 row). Rows must arrive in depth-first
|
||||||
* or a collapsed ancestor (⇒ hidden). This is independent of the order rows
|
* order — the order the compare/budget services already emit them in, and the
|
||||||
* arrive in — the fix for the v1 adjacency algorithm, which assumed a
|
* same order `reorderRows` relies on.
|
||||||
* depth-first order the budget grid (level-ordered) does not emit.
|
|
||||||
*
|
*
|
||||||
* The accessors are supplied by each consumer so every table can key + climb the
|
* The accessors are passed in rather than read off fixed field names so each
|
||||||
* exact hierarchy it renders. Keys are prefixed `p:` so they are injective: a
|
* table can supply the *exact* depth expression it renders with (CategoryDelta
|
||||||
* parent subtotal row and its own "(direct)" leaf share a category id but the
|
* uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from
|
||||||
* leaf is never a parent, so only the subtotal is indexed — the leaf simply
|
* `parent_id`). Keeping the collapse depth and the indentation depth identical
|
||||||
* carries the parent's key via `parentKeyOf` and is hidden with it.
|
* guarantees the hidden rows are exactly the indented descendants.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* 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-parent key, injective across the section: `p:<categoryId>`. */
|
/** Stable per-group key (a category id, stringified). */
|
||||||
keyOf: (row: T) => string;
|
keyOf: (row: T) => string;
|
||||||
/** Key of this row's parent (`p:<parent_id>`), or null at a root. */
|
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */
|
||||||
parentKeyOf: (row: T) => string | null;
|
depthOf: (row: T) => number;
|
||||||
/** 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 groups are collapsed. A
|
* Keeps every row that is currently visible given which top-level groups are
|
||||||
* row is dropped iff any ancestor (walking `parentKeyOf`) is collapsed; a missing
|
* collapsed. `isCollapsed` is consulted only for top-level parent rows; a
|
||||||
* ancestor (parent lives in another section) leaves the row visible.
|
* collapsed one keeps its own (subtotal) row and drops its whole subtree.
|
||||||
*/
|
*/
|
||||||
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[] {
|
||||||
// Index ONLY parents: a "(direct)" leaf shares its parent's category id but is
|
const out: T[] = [];
|
||||||
// never a parent itself, so it never clobbers the subtotal it points at.
|
// While true we are inside a collapsed top-level parent's subtree and drop
|
||||||
const parents = new Map<string, T>();
|
// every deeper row until the next depth-0 row re-opens the flow.
|
||||||
for (const r of rows) if (acc.isParent(r)) parents.set(acc.keyOf(r), r);
|
let hidingSubtree = false;
|
||||||
|
for (const row of rows) {
|
||||||
const hiddenByAncestor = (row: T): boolean => {
|
if (acc.depthOf(row) === 0) {
|
||||||
let key = acc.parentKeyOf(row);
|
out.push(row);
|
||||||
let hops = 0;
|
hidingSubtree = acc.isParent(row) && isCollapsed(row);
|
||||||
while (key !== null && hops++ < MAX_TREE_DEPTH) {
|
} else if (!hidingSubtree) {
|
||||||
const parent = parents.get(key);
|
out.push(row);
|
||||||
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 (all parent rows, any depth), in encounter order. */
|
/** Keys of every collapsible group (top-level parent rows), 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.isParent(row)) keys.push(acc.keyOf(row));
|
if (acc.depthOf(row) === 0 && acc.isParent(row)) keys.push(acc.keyOf(row));
|
||||||
}
|
}
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polarity of the collapse state. The persisted Set holds the keys whose state
|
* Parses the persisted value into the set of *expanded* group keys. The report
|
||||||
* DIFFERS from the default, which lets one hook serve both polarities without a
|
* default is "everything collapsed" (issue #254/#260), so an absent or corrupt
|
||||||
* seed:
|
* value yields an empty set — i.e. all groups collapsed on a first-ever visit.
|
||||||
* - `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();
|
||||||
|
|
@ -108,12 +75,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 default state.
|
// Corrupt value: fall back to the collapsed default.
|
||||||
}
|
}
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialises the flipped-key set for persistence. */
|
/** Serialises the set of expanded group keys for persistence. */
|
||||||
export function serializeExpanded(flipped: Set<string>): string {
|
export function serializeExpanded(expanded: Set<string>): string {
|
||||||
return JSON.stringify([...flipped]);
|
return JSON.stringify([...expanded]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue