Compare commits

..

No commits in common. "5a3d87b31f527d207eb80dc62fcf1d878afd854e" and "524fe162ea584b802cc000dc734ddc2429b75008" have entirely different histories.

25 changed files with 640 additions and 1539 deletions

View file

@ -2,15 +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).
### Corrigé
- Arbre des catégories standard (le guide autonome comme l'étape d'aperçu de l'assistant de migration des catégories) : le bouton « Tout déplier / Tout replier » reflète désormais l'état réel de l'arbre — il reste sur « Tout déplier » tant que tous les groupes ne sont pas ouverts, au lieu de basculer sur « Tout replier » dès qu'une seule catégorie était dépliée (#290).
## [0.13.0] - 2026-07-12 ## [0.13.0] - 2026-07-12
### Ajouté ### Ajouté

View file

@ -2,15 +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).
### Fixed
- Standard categories tree (both the standalone guide and the category-migration wizard's overview step): the "Expand all / Collapse all" button now reflects the tree's real state — it stays on "Expand all" until every group is open, instead of flipping to "Collapse all" as soon as a single category was expanded (#290).
## [0.13.0] - 2026-07-12 ## [0.13.0] - 2026-07-12
### Added ### Added

View file

@ -1,87 +0,0 @@
# ADR 0016 — Persistance de l'état UI par profil : le repli des catégories vit dans `user_preferences`
- Status: **Accepted**
- Date: 2026-07-15
- Issues: #288 (socle multi-niveaux + 3 rapports + bascule vers `user_preferences`), #289 (grille budget), #290 (unification des deux arbres de catégories), #291 (cet ADR + doc)
- Spec: [`spec-decisions-collapse-multi-niveaux.md`](../../spec-decisions-collapse-multi-niveaux.md), [`spec-plan-collapse-multi-niveaux.md`](../../spec-plan-collapse-multi-niveaux.md) (plan v2)
- S'appuie sur [ADR 0005](0005-multi-profile-db.md) (bases SQLite séparées par profil) et [ADR 0002](0002-useReducer-vs-redux.md) (état local, pas de store global)
## Contexte
Le repli/dépli des catégories a d'abord été introduit sur les deux rapports comparables (issue #254/#265) : un `Set` d'ID de catégories par rapport, persisté en **`localStorage`**, replié par défaut. La généralisation (issues #288/#289/#290) a étendu ce mécanisme à **tous les niveaux** de la hiérarchie et à **six surfaces** : les trois tableaux de rapports hiérarchiques (comparable réel-vs-réel `ComparePeriodTable`, réel-vs-budget `BudgetVsActualTable`, tendance par catégorie `CategoryOverTimeTable`), la **grille budgétaire** (`BudgetTable`), et les **deux arbres de gestion des catégories** (`CategoryTree` de la page Catégories + `CategoryTaxonomyTree` du guide standard et de l'assistant de migration).
Cette généralisation a forcé une question qui n'était pas tranchée en #254 : **où vit l'état de repli ?** Deux propriétés du produit rendent le choix structurant, pas cosmétique.
1. **L'app est multi-profils à bases séparées** ([ADR 0005](0005-multi-profile-db.md)) : chaque profil a sa **propre base SQLite**, ses propres catégories, ses propres ID de catégories. Un profil peut être **protégé par un NIP** (Argon2). Supprimer un profil = **supprimer son fichier `.db`** (`deleteProfileDb` → commande Rust `delete_profile_db`) + retirer son entrée de `profiles.json`. Rien d'autre n'est nettoyé — `deleteProfile` (`ProfileContext`) ne touche **jamais** `localStorage`.
2. **`localStorage` est machine-global**, partagé par tous les profils du poste. La clé de repli est un ensemble d'**ID de catégories** (`p:<categoryId>`), et ces ID n'ont de sens que **dans la base d'un profil donné** : le même ID numérique désigne des catégories différentes selon le profil. Une clé de repli en `localStorage` serait donc soit **en collision entre profils** (mêmes ID, catégories différentes), soit suffixée par `profileId` — et dans ce dernier cas, elle **survivrait à la suppression du profil**, devenant un **résidu**.
Ce résidu n'est pas anodin : l'ensemble des catégories qu'un profil a **dépliées** révèle quelles postes de dépense l'utilisateur a explorés. Le laisser traîner en `localStorage` après la suppression d'un profil (potentiellement NIP-protégé) trahit une part de son comportement financier. C'est **incompatible avec le principe privacy-first** (non négociable) du projet.
## Décision
**L'état UI *spécifique à un profil* est persisté dans la table `user_preferences` de la base du profil ; l'état UI *machine-global* reste dans `localStorage`.** Le repli des catégories relève du premier cas.
### 1. Frontière `user_preferences` vs `localStorage`
| Nature de l'état | Support | Pourquoi | Exemples |
|---|---|---|---|
| **Spécifique au profil** (dépend des données du profil, ou révèle son usage) | `user_preferences` (base SQLite du profil) | Scoping par-profil **gratuit** (la clé vit dans la base du profil) ; **détruit avec le profil** (drop du `.db`) ⇒ zéro résidu | **Repli des catégories** des rapports + grille budget (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) ; `import_folder`, `balance_show_returns`, `balance_starter_proposed` (préexistants) |
| **Machine-globale** (scalaire agnostique du profil, ne révèle rien de sensible) | `localStorage` | On *veut* la même valeur quel que soit le profil ouvert ; aucune fuite si elle survit à une suppression | Thème clair/sombre (`theme`), position des sous-totaux (`*-subtotals-position`), mode de période Cartes (`reports-cartes-period-mode`), bascule graphique/tableau par rapport |
Le critère : une préférence est **machine-globale** quand sa valeur est un scalaire agnostique du profil (`"top"`/`"bottom"`, `"dark"`, `"chart"`) qu'on souhaite partager entre tous les profils du poste. Elle est **spécifique au profil** dès qu'elle est indexée par des identifiants propres à la base du profil (les ID de catégories du repli) **ou** qu'elle expose l'usage d'un profil donné.
### 2. Mécanisme — `useCollapsibleGroups(storageKey: string | null)`
Le hook `useCollapsibleGroups` porte cette décision via un unique paramètre `storageKey` :
- **`storageKey` non nul** ⇒ persistance dans `user_preferences` via `userPreferenceService` (`getPreference`/`setPreference`, UPSERT sur `key`). Utilisé par les **quatre surfaces persistées** (3 rapports + budget). La valeur sérialisée est le `Set` des clés dont l'état **diffère du défaut** — ce qui permet un défaut « tout replié » sans graine (Set vide = tout replié pour `defaultExpanded: false`).
- **`storageKey === null`** ⇒ **aucune persistance**, état purement en mémoire, réinitialisé au défaut à chaque montage. Utilisé par les **deux arbres de catégories** (page Catégories, guide standard, assistant de migration) : leur repli est un confort de navigation éphémère, sans intérêt à conserver ni à révéler.
Le hook reste **sans contexte** — il n'appelle jamais `useProfile()` : la clé `user_preferences` est **déjà** par-profil, puisqu'elle vit dans la base du profil actif. Aucun `profileId` à faufiler.
### 3. Rationale privacy — le résidu qui survit à la suppression
C'est l'argument central. `deleteProfile` détruit la base (`.db`) mais **ne purge aucun `localStorage`**. Une clé de repli par-profil en `localStorage` (ex. `reports-compare-expanded:<profileId>`) survivrait donc à la suppression du profil, laissant deviner **quelles catégories** un profil désormais supprimé — et peut-être NIP-protégé — avait explorées. En logeant l'état dans `user_preferences`, il **disparaît avec le fichier `.db`** : la suppression du profil emporte automatiquement son état UI, sans code de nettoyage dédié et sans surface de fuite. La propriété « supprimer un profil = supprimer un fichier » ([ADR 0005](0005-multi-profile-db.md)) est préservée telle quelle.
## Alternatives considérées
### A. `localStorage` suffixé par `profileId` — **rejeté**
Garder le repli en `localStorage` avec une clé `…:<profileId>` par profil. Rejeté : c'est précisément le **résidu** décrit ci-dessus (survit à `deleteProfile`, fuite privacy), et il faudrait ajouter un balayage de nettoyage dans `deleteProfile` — du code fragile qui doit connaître toutes les clés UI par-profil de l'app, à maintenir à chaque nouvelle surface repliable.
### B. Table SQLite dédiée (`ui_collapse_state`) — rejeté
Créer une table par-profil rien que pour le repli. Rejeté : sur-dimensionné. `user_preferences` est déjà une table clé-valeur par-profil faite pour ça (`import_folder`, `balance_show_returns`…) ; une clé par surface suffit, sans migration.
### C. Tout en mémoire (aucune persistance) — rejeté pour les rapports/budget
Simple, zéro résidu. Rejeté pour les **quatre** surfaces persistées : sur des tableaux profonds, re-replier à chaque visite est une friction réelle (finding remonté en #254). **Retenu en revanche pour les deux arbres de catégories** (`storageKey: null`) — d'où le paramètre nullable plutôt qu'un choix uniforme.
## Consequences
### Positives
- **Zéro résidu, zéro fuite** : l'état de repli est détruit avec le `.db` du profil ; aucune trace machine-globale de l'usage d'un profil supprimé. Conforme privacy-first.
- **Scoping par-profil gratuit** : la clé vit dans la base du profil ⇒ isolation automatique, hook sans contexte, pas de `profileId` à propager.
- **Réutilise l'existant** : `user_preferences` + `userPreferenceService` déjà en place ⇒ **aucune migration, aucune table, aucun changement de schéma**.
- **Frontière tracée** : la règle « profil-spécifique → `user_preferences` ; machine-globale → `localStorage` » est explicite (§1) ⇒ le prochain état UI par-profil ne repartira pas en `localStorage` par défaut (caveat de review levé).
### Négatives / risques actés
- **Hydratation asynchrone** : `getPreference`/`setPreference` sont `async` (passent par `getDb()`). L'état initial rendu est le **défaut** (tout replié pour les rapports/budget), puis un `useEffect` hydrate depuis `user_preferences`. **Pas de flash visible** : le défaut EST ce qui s'affiche en premier, et l'hydratation ne peut que **révéler** ce que l'utilisateur avait déplié (jamais masquer ce qui était visible). Acceptable — c'est la contrepartie assumée du choix.
- **Best-effort** : une lecture/écriture en échec retombe silencieusement sur l'état par défaut (jamais de crash). Une écriture perdue = la session suivante rouvre au défaut.
- **Frontière à faire respecter dans la durée** : rien dans le type ne force un futur état par-profil à choisir `user_preferences`. La règle est documentaire (cet ADR + `docs/architecture.md`) — d'où l'importance de l'avoir tracée.
### Neutre
- Les préférences machine-globales existantes (`theme`, `*-subtotals-position`, `reports-cartes-period-mode`, bascule graphique/tableau) **restent en `localStorage`** : elles sont volontairement partagées entre profils et ne révèlent rien de sensible en cas de suppression. Aucune migration de ces clés n'est entreprise.
## Liens
- [ADR 0005](0005-multi-profile-db.md) — bases SQLite séparées par profil ; « supprimer un profil = supprimer un fichier `.db` » (fondement de l'argument résidu)
- [ADR 0002](0002-useReducer-vs-redux.md) — état local par domaine, pas de store global (le repli reste un état UI local persisté à la marge)
- `src/hooks/useCollapsibleGroups.ts` — implémentation (`storageKey: string | null`, `defaultExpanded`, hydratation async)
- `src/utils/collapsibleRows.ts` — helpers purs (marche des ancêtres, polarité du `Set`)
- `src/services/userPreferenceService.ts``getPreference`/`setPreference` (UPSERT sur `user_preferences`)
- Issues #254/#265 (repli d'origine, localStorage) → #288/#289/#290 (généralisation + bascule `user_preferences`) → #291 (cette doc)

View file

@ -89,7 +89,7 @@ simpl-resultat/
| `budget_templates` | Modèles de budget réutilisables | | `budget_templates` | Modèles de budget réutilisables |
| `budget_template_entries` | Catégories et montants dans les modèles | | `budget_template_entries` | Catégories et montants dans les modèles |
| `import_config_templates` | Modèles prédéfinis de config d'import | | `import_config_templates` | Modèles prédéfinis de config d'import |
| `user_preferences` | Préférences applicatives (clé-valeur) `import_folder`, `balance_show_returns`, `balance_starter_proposed`**et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) | | `user_preferences` | Préférences applicatives (clé-valeur) |
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) | | `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` | | `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer | | `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
@ -221,13 +221,6 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useLicense` | État de la licence et entitlements | | `useLicense` | État de la licence et entitlements |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) | | `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
### Hook transverse — `useCollapsibleGroups`
`useCollapsibleGroups<T>(storageKey: string | null, accessors, { defaultExpanded })` est un hook **utilitaire d'état UI** (basé `useState`, hors convention `useReducer` par domaine) partagé par toutes les surfaces à hiérarchie repliable : les 3 tableaux de rapports (`ComparePeriodTable`, `BudgetVsActualTable`, `CategoryOverTimeTable`), la grille budget (`BudgetTable`) et les 2 arbres de catégories (`CategoryTree`, `CategoryTaxonomyTree`). Les helpers purs (marche des ancêtres pour la visibilité, polarité du `Set` selon `defaultExpanded`) vivent dans `src/utils/collapsibleRows.ts`.
- `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible.
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
## Commandes Tauri (36) ## Commandes Tauri (36)
### `fs_commands.rs` — Système de fichiers (6) ### `fs_commands.rs` — Système de fichiers (6)
@ -436,4 +429,3 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
| [0013](adr/0013-stocks-provider-evaluation.md) | Évaluation provider stocks : Alpha Vantage retenu comme cible | 2026-05-09 | Accepted | | [0013](adr/0013-stocks-provider-evaluation.md) | Évaluation provider stocks : Alpha Vantage retenu comme cible | 2026-05-09 | Accepted |
| [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted | | [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted |
| [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted | | [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted |
| [0016](adr/0016-persistance-etat-ui-par-profil.md) | Persistance de l'état UI par profil : repli des catégories dans `user_preferences` | 2026-07-15 | Accepted |

View file

@ -226,7 +226,6 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
- Répartition égale du montant annuel sur 12 mois - Répartition égale du montant annuel sur 12 mois
- Modèles de budget pour sauvegarder et appliquer des configurations - Modèles de budget pour sauvegarder et appliquer des configurations
- Sous-totaux par catégorie parente - Sous-totaux par catégorie parente
- Repli / dépli de la hiérarchie à **chaque niveau** : chaque catégorie parente a son chevron, la grille s'ouvre **entièrement repliée** (seules les catégories de premier niveau visibles), et un bouton **« Tout déplier / Tout replier »** ouvre ou ferme tous les niveaux d'un coup — votre choix est mémorisé **par profil**
- En-têtes de colonnes fixes au défilement vertical - En-têtes de colonnes fixes au défilement vertical
### Comment faire ### Comment faire
@ -242,7 +241,6 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
- La colonne Annuel additionne automatiquement les 12 mois — un avertissement apparaît si les totaux mensuels ne correspondent pas - La colonne Annuel additionne automatiquement les 12 mois — un avertissement apparaît si les totaux mensuels ne correspondent pas
- Les modèles peuvent être appliqués à des mois spécifiques ou aux 12 mois d'un coup - Les modèles peuvent être appliqués à des mois spécifiques ou aux 12 mois d'un coup
- Les catégories parentes affichent les sous-totaux agrégés de leurs enfants - Les catégories parentes affichent les sous-totaux agrégés de leurs enfants
- Replier une catégorie est **purement visuel** : les sous-totaux et les totaux annuels sont toujours calculés sur toutes les lignes, jamais seulement sur les lignes visibles — aucun montant saisi n'est perdu quand vous repliez
--- ---
@ -321,7 +319,7 @@ Toujours mensuelle, indépendamment du toggle.
### Rapport Tendances (`/reports/trends`) ### Rapport Tendances (`/reports/trends`)
- **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau - **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau
- **Par catégorie** : évolution de chaque catégorie, en lignes ou tableau. En mode tableau, la hiérarchie se **replie / déplie à chaque niveau** (voir ci-dessous) - **Par catégorie** : évolution de chaque catégorie, en lignes ou tableau pivot
### Rapport Comparables (`/reports/compare`) ### Rapport Comparables (`/reports/compare`)
@ -331,10 +329,6 @@ Trois modes accessibles via un tab bar :
- **Année vs année précédente** — même logique sur 12 mois vs 12 mois - **Année vs année précédente** — même logique sur 12 mois vs 12 mois
- **Réel vs budget** — reprend la vue Budget vs Réel avec ses totaux mensuels et cumul annuel - **Réel vs budget** — reprend la vue Budget vs Réel avec ses totaux mensuels et cumul annuel
#### Repli de la hiérarchie
Les trois modes ci-dessus, comme le tableau **Par catégorie** des Tendances, présentent les catégories en arbre repliable **à chaque niveau**. Chaque catégorie parente — y compris les niveaux intermédiaires — porte son propre chevron : le déplier révèle ses enfants directs (eux-mêmes repliés), de sorte que vous descendez d'un niveau à la fois. Le tableau s'ouvre **entièrement replié** (seules les catégories de premier niveau visibles) ; le bouton **« Tout déplier / Tout replier »** ouvre ou ferme tous les niveaux d'un coup. Replier une catégorie est **purement visuel** — les sous-totaux, totaux et lignes de résultat restent calculés sur l'ensemble des lignes, jamais sur les seules lignes visibles. Vos choix de repli sont **mémorisés par profil** (dans la base du profil, ils disparaissent donc avec lui).
### Rapport Zoom catégorie (`/reports/category`) ### Rapport Zoom catégorie (`/reports/category`)
Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez : Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez :

View file

@ -1,72 +0,0 @@
# Spec Decisions — Repli/dépliage à chaque niveau de la hiérarchie
> Date: 2026-07-13
> Projet: simpl-resultat
> Statut: Draft
> Slug: collapse-multi-niveaux
## Contexte
Le repli des catégories a été livré en deux temps — #254 (les deux rapports comparables) puis #265 (la tendance par catégorie) — mais avec une limite qui n'avait pas été explicitée : **on ne peut replier que les catégories de premier niveau**.
La taxonomie standard compte pourtant **trois niveaux** : 11 racines, **41 catégories intermédiaires**, 98 feuilles. Ces 41 catégories intermédiaires (par exemple `Alimentation > Épicerie`) sont aujourd'hui des culs-de-sac visuels : elles portent un sous-total, un style dédié et une indentation, mais **aucun chevron**. Déplier une racine révèle d'un coup tout son sous-arbre, feuilles comprises — impossible de s'arrêter au palier intermédiaire.
La restriction n'est pas structurelle. Elle tient dans deux fonctions de `src/utils/collapsibleRows.ts` : `visibleRows` ne consulte l'état de repli qu'à `depth === 0`, et `collapsibleKeys` ne liste que les parents de premier niveau. Tout le reste de la chaîne est déjà prêt : le builder d'arbre (`buildLeafDrivenTree`) est récursif à profondeur arbitraire, chaque ligne porte sa propre `depth`, les sous-totaux sont exacts à chaque palier, et l'indentation gère déjà quatre niveaux.
En parallèle, la **grille Budget** est hiérarchique (elle lit `depth` / `is_parent`, elle indente) mais a été **délibérément exclue du repli** par #278 : « elle demeure une surface d'édition, sans ligne masquée ni repliable ». Avec ~150 lignes affichées d'un bloc, cette décision est revue ici.
## Objectif
Rendre chaque catégorie parente repliable **à n'importe quel niveau de la hiérarchie**, sur les trois rapports hiérarchiques et sur la grille Budget. Tous les niveaux s'ouvrent **repliés** : déplier un parent révèle uniquement le palier suivant, lui-même replié, ce qui permet de forer par étapes au lieu de subir un mur de lignes.
## Scope
### IN
- **Généralisation du socle**`collapsibleRows.ts` : le repli devient valable à toute profondeur (aujourd'hui `depth === 0` en dur).
- **Sémantique en cascade** — déplier un parent révèle ses enfants directs, eux-mêmes repliés. Un descendant n'est visible que si **tous** ses ancêtres sont dépliés.
- **Les 3 rapports hiérarchiques**`ComparePeriodTable` (réel-vs-réel), `BudgetVsActualTable` (réel-vs-budget), `CategoryOverTimeTable` (tendances par catégorie) : chevron sur les parents intermédiaires, qui n'ont aujourd'hui qu'un `<span>` muet.
- **La grille Budget** (`BudgetTable`) — nouveau consommateur du repli : elle ne l'a jamais eu. Revient sur la décision de #278.
- **Le bouton « Tout déplier / Tout replier »** — agit désormais sur tous les niveaux d'un coup.
- **Correctifs d'hygiène** dans les fichiers touchés :
- collision de clé `localStorage` `subtotals-position`, partagée entre `BudgetVsActualTable.tsx:30` et `BudgetTable.tsx:21` ;
- état de repli non scopé par profil (l'app est multi-profils, les ids de catégories divergent d'un profil à l'autre) ;
- suppression de `src/components/reports/CategoryTable.tsx` (74 lignes, importé nulle part).
- **`aria-level`** sur les lignes parentes : avec plusieurs niveaux imbriqués, la profondeur n'est plus déductible pour un lecteur d'écran (l'indentation est du padding CSS, invisible pour lui).
### OUT (explicitement exclu)
- **Dashboard, Cartes, Faits saillants, Par-catégorie** — aucun tableau hiérarchique de catégories à replier : ce sont des donuts, des top-N plats et des cartes de KPI. « Tous les rapports » ne s'y applique pas.
- **Page Catégories (`/categories`) et guide de la taxonomie standard** — les deux ont déjà leur propre repli à N niveaux, avec leur code, non persisté, et des défauts opposés (Catégories = tout déplié ; guide = tout replié). Les unifier sur le hook partagé est un chantier distinct : la page Catégories est une surface CRUD avec drag-and-drop, où « tout replié par défaut » nuirait à l'usage.
- **Page Bilan** — sa hiérarchie porte sur des **comptes**, pas des catégories, et son mécanisme d'expansion est entièrement distinct (drill-down des titres, colonnes de rendement persistées en DB).
- **Migration vers `role="treegrid"`** — le pattern ARIA complet exigerait toute la navigation clavier bidirectionnelle (flèches, Home/End, Ctrl+Home…). Hors sujet ici ; `aria-expanded` sur le bouton de repli reste valide et conforme. À noter comme dette.
- **Niveau 4 et au-delà** — l'UI l'interdit déjà à la création (`CategoryForm.tsx:44-63`) comme au drag-and-drop (`CategoryTree.tsx:283-287`). Le socle reste néanmoins agnostique de la profondeur (le garde anti-cycle `MAX_TREE_DEPTH = 5` est conservé).
## Decisions prises
| Question | Decision | Raison |
|----------|----------|--------|
| Quelles surfaces reçoivent le repli multi-niveaux ? | Les 3 rapports hiérarchiques **+ la grille Budget** | Ce sont les seules surfaces réellement hiérarchiques. Dashboard/Cartes/Faits saillants n'ont aucun tableau de catégories imbriqué — il n'y a rien à y replier. |
| Que révèle le dépliage d'un parent ? | **Le palier suivant, replié** (cascade) | Fidèle à « chaque niveau est replié par défaut ». Permet de forer par étapes. Assume un changement de comportement : aujourd'hui, déplier une racine révèle tout son sous-arbre d'un coup. |
| État par défaut de la grille Budget ? | **Replié, comme partout** | Uniformité. La page ouvre sur 11 lignes compactes au lieu de ~150. Coût assumé : à la première visite, aucun champ de saisie n'est visible (seules les feuilles sont éditables) — mais l'état étant persisté, ce coût n'est payé qu'une fois, et « Tout déplier » est à un clic. |
| Portée du bouton « Tout déplier / Tout replier » ? | **Tous les niveaux d'un coup** | C'est l'échappatoire qui rend le défaut replié vivable, en particulier sur la grille de saisie. Sans lui, il faudrait ouvrir les 41 intermédiaires un par un. |
| Corriger la collision `subtotals-position` ? | **Oui** | Bug préexistant : déplacer les sous-totaux dans la grille Budget les déplace aussi dans le rapport réel-vs-budget. Une ligne, dans un fichier qu'on ouvre de toute façon. |
| Scoper l'état de repli par profil ? | **Oui** | Le repli multi-niveaux aggrave le défaut : on mémorise désormais des ids de catégories à tous les niveaux, et deux profils aux taxonomies divergentes se marchent dessus. |
| Supprimer `reports/CategoryTable.tsx` ? | **Oui** | Code mort (0 import), vestige plat d'avant les tables hiérarchiques. Évite qu'on le confonde un jour avec une surface à replier. |
| Découpage du travail ? | **Deux issues stackées** — A : socle + les 3 rapports ; B (dépend de A) : adoption sur la grille Budget | Sépare un changement de ~15 lignes bien testé de l'ajout du repli sur une surface d'édition, où la régression est plus plausible. A peut être mergée sans porter le risque de B. |
## Contraintes techniques établies (vérifiées dans le code)
- **La couche données est déjà prête.** `buildLeafDrivenTree` (`reportService.ts:561-686`) et `buildSubGroup` (`budgetService.ts:351-374`) sont récursifs à profondeur arbitraire ; chaque ligne porte `depth` / `is_parent` / `parent_id` ; les sous-totaux sont exacts à chaque palier. Le rendu indente déjà quatre niveaux (`px-3` / `pl-8` / `pl-14` / `pl-20`) et style déjà les parents intermédiaires. **Il ne leur manque que le chevron.**
- **Le bouton bulk fonctionnera sans toucher aux appelants.** `expandAll` / `allExpanded` / `groupCount` reçoivent les lignes **complètes** (`rows` / `data.tree`), le filtre `visible()` étant appliqué séparément au rendu. Généraliser `collapsibleKeys` pour qu'il retourne tous les parents suffit donc à couvrir tous les niveaux.
- **Le repli reste purement visuel.** Sous-totaux, « Résultat avant transferts » et « Résultat net » sont calculés sur les lignes brutes, jamais sur les lignes visibles — invariant vérifié en revue de #254 et couvert par les tests. Il doit le rester.
- **Seules les feuilles sont éditables dans la grille Budget** (`BudgetTable.tsx:105`, `:175`) : les parents sont des sous-totaux en lecture seule. C'est ce qui fonde le compromis sur le défaut replié.
- **Les tests actuels verrouillent la sémantique mono-niveau** (`collapsibleRows.test.ts:53-63` et `:82-84` affirment explicitement que déplier révèle tout le sous-arbre et que seuls les parents de niveau 1 sont repliables). **Ils devront être réécrits** — ce n'est pas une régression, c'est le changement demandé.
- **Pas de harnais de rendu React** dans le repo (ni jsdom ni testing-library). Toute la logique doit rester dans des modules purs testables (`collapsibleRows.ts`, `overTimeTableModel.ts`), les composants restant de simples afficheurs.
## References
| Source | Pertinence |
|--------|------------|
| [Treegrid Pattern — WAI-ARIA APG](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/) | Confirme que les lignes parentes portent `aria-expanded`, et que les lignes **sans** enfants ne doivent **pas** porter l'attribut (l'inclure les définirait comme parentes). Notre gate actuel — bouton présent uniquement sur les parents — est conforme et le reste après généralisation. |
| [ARIA: treegrid role — MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/treegrid_role) | Un vrai `treegrid` impose la navigation clavier bidirectionnelle complète. Trop coûteux pour ce chantier → on conserve le disclosure par bouton et on ajoute `aria-level`, qui porte l'information de profondeur qu'un lecteur d'écran ne peut pas tirer du padding CSS. |

View file

@ -1,297 +0,0 @@
# Spec Plan — Repli/dépliage à chaque niveau de la hiérarchie
> Date: 2026-07-13 (révisé après `/review-spec`)
> Projet: simpl-resultat
> Statut: Gelé — décisions tranchées
> Slug: collapse-multi-niveaux
> Decisions: [spec-decisions-collapse-multi-niveaux.md](./spec-decisions-collapse-multi-niveaux.md)
> **Ce plan est la v2.** La v1 reposait sur un algorithme « cascade » guidé par la profondeur, qui supposait que les lignes arrivent en ordre DFS. Les trois experts de `/review-spec` ont indépendamment démontré que **c'est faux** (la grille Budget émet un ordre par niveau). Voir la synthèse en fin de document.
## Design
### UX / Interface
Inchangé par rapport à la v1 :
- Tout est **replié** à l'ouverture : seules les 11 racines sont visibles.
- Déplier une catégorie révèle **ses enfants directs, eux-mêmes repliés**.
- **Chaque parent porte un chevron**, à n'importe quelle profondeur.
- « Tout déplier » ouvre tous les niveaux d'un coup ; « Tout replier » ne laisse que les racines.
- L'état est **persisté par rapport**, et naturellement isolé par profil (voir Données).
### Données
**Aucune migration. Aucun changement de schéma.** Mais — correction de la v1 — **deux modules de données sont bien touchés** : `useBudget.ts` (le 3ᵉ builder, que la v1 ignorait) est **lu** pour vérifier l'invariant d'ordre, et `userPreferenceService.ts` devient le support de persistance.
**L'état de repli migre de `localStorage` vers `user_preferences`.** La table `user_preferences (key TEXT PRIMARY KEY, value TEXT NOT NULL)` vit **dans la base SQLite du profil** : elle est créée et détruite avec lui. La clé est donc **scopée par profil gratuitement** — pas de préfixe `${profileId}:`, pas de helper `scopedStorageKey`, pas de risque de ré-hydratation, et surtout **aucun résidu** après suppression d'un profil.
C'est une exigence de principe : `deleteProfile` détruit le fichier SQLite mais ne purge **aucun** `localStorage` (aucun `removeItem` en production). Des clés de repli en `localStorage` auraient été le premier résidu par-profil survivant à la suppression, révélant quelles catégories un profil (potentiellement protégé par PIN) a explorées. Incompatible avec le principe privacy-first.
L'hydratation devient **asynchrone** (`await getPreference(key)`). **Sans flash visible** : le défaut est « tout replié », donc l'état initial affiché *est* l'état par défaut — l'hydratation ne peut que déplier ce que l'utilisateur avait ouvert.
Clés (dans `user_preferences`) : `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`.
### Architecture
#### Le cœur : visibilité par chaîne d'ancêtres (et non par adjacence)
La v1 suivait un curseur de profondeur sur des lignes supposées adjacentes. **On remonte désormais `parent_id`** : une ligne est visible ⟺ **tous ses ancêtres sont dépliés**.
```ts
export interface CollapseAccessors<T> {
keyOf: (row: T) => string; // 'p:<categoryId>' — seuls les parents sont keyés
parentKeyOf: (row: T) => string | null; // NOUVEAU — 'p:<parent_id>', ou null à la racine
isParent: (row: T) => boolean;
depthOf: (row: T) => number; // CONSERVÉ, mais pour l'indentation + aria-level UNIQUEMENT
}
export function visibleRows<T>(rows, acc, isCollapsed): T[] {
// N'indexer QUE les parents : une feuille « (direct) » porte la clé de son parent.
const parents = new Map<string, T>();
for (const r of rows) if (acc.isParent(r)) parents.set(acc.keyOf(r), r);
const hiddenByAncestor = (row: T): boolean => {
let key = acc.parentKeyOf(row);
let hops = 0;
while (key !== null && hops++ < MAX_TREE_DEPTH) { // garde anti-cycle (parent_id corrompu)
const parent = parents.get(key);
if (parent === undefined) return false; // ancêtre hors de cette section → visible
if (isCollapsed(parent)) return true;
key = acc.parentKeyOf(parent);
}
return false;
};
return rows.filter((r) => !hiddenByAncestor(r));
}
export function collapsibleKeys<T>(rows, acc): string[] {
return rows.filter(acc.isParent).map(acc.keyOf); // tous les parents, toute profondeur
}
```
**Pourquoi c'est le bon choix — ça résout quatre problèmes d'un coup :**
1. **L'ordre par niveau de la grille Budget** (`useBudget.ts:361` trie par `depth` croissant) — l'algorithme est désormais indépendant de l'ordre des lignes.
2. **Le tri final par type de catégorie**, présent dans les trois builders, peut placer un enfant dans une *autre section* que son parent (`CategoryForm` autorise un type d'enfant différent de celui du parent) — l'ancêtre absent de la section rend simplement la ligne visible, sans orphelin masqué à tort.
3. **La feuille « (direct) »** — elle porte `parent_id === son propre category_id`, donc sa chaîne remonte vers le sous-total de son parent : replier le parent la masque, **par construction**. Le garde `isParent` redevient **cosmétique** (« pas de chevron sur une feuille »), il n'est plus load-bearing.
4. **La contrainte « `visible()` avant `reorderRows` »** disparaît — `reorderRows` peut déplacer les sous-totaux en bas d'un groupe sans rien casser.
**`keyOf` devient injectif** : le préfixe `p:` marque que seuls les parents sont keyés (aujourd'hui, un sous-total parent et sa feuille « (direct) » partagent `String(categoryId)`). Coût de migration nul — le passage à `user_preferences` repart de toute façon d'un état vierge.
#### Le hook : polarité + persistance optionnelle
```ts
useCollapsibleGroups<T>(
storageKey: string | null, // null ⇒ aucune persistance (état React pur)
acc: CollapseAccessors<T>,
options?: { defaultExpanded?: boolean }, // défaut false
): CollapsibleGroups<T>
```
**Le Set persisté stocke les clés dont l'état DIFFÈRE du défaut.** C'est ce qui permet de servir les deux polarités sans seed :
```ts
const isCollapsed = (row) => {
const flipped = flippedKeys.has(acc.keyOf(row));
return defaultExpanded ? flipped : !flipped;
};
```
- `defaultExpanded: false` (rapports, budget) ⇒ le Set = les **dépliés**. Set vide = tout replié. **Strictement le comportement actuel.**
- `defaultExpanded: true` (page Catégories) ⇒ le Set = les **repliés**. Set vide = tout déplié. Aucun seed nécessaire.
Conséquence sur l'API : **`collapseAll` prend désormais `rows`** (`collapseAll(rows: T[])`), symétrique d'`expandAll`, puisqu'en polarité `defaultExpanded` il doit remplir le Set.
**Le hook reste sans dépendance au contexte** : il n'appelle pas `useProfile()` (qui *throw* hors de son provider et coupleraient un util générique à `ProfileContext`). La clé `user_preferences` étant déjà propre au profil, il n'y a rien à injecter.
#### Les 4 composants — deux gates, pas un
La v1 ne voyait que le gate du bouton. Il y en a **deux** :
```tsx
const collapsed = isTopParent && groups.isCollapsed(row); // (1) → isParent && ...
...
{isTopParent ? <button>{chevron}</button> : <span>} // (2) → isParent ? ...
```
Sans corriger **(1)**, chaque parent intermédiaire afficherait un chevron *perpétuellement ouvert* et un `aria-expanded={true}` mensonger, alors que son sous-arbre est masqué.
## Plan de travail
### Issue A — Socle : repli à chaque niveau sur les 3 rapports hiérarchiques [type:feature] (#288)
Dependances : aucune
- [ ] `src/utils/collapsibleRows.ts` — réécrire `visibleRows` en **remontée d'ancêtres** (code ci-dessus) ; ajouter `parentKeyOf` à `CollapseAccessors` ; `collapsibleKeys` retourne tous les parents ; `keyOf` préfixé `p:`.
- [ ] `src/utils/collapsibleRows.test.ts`**réécrire** (les tests actuels verrouillent le mono-niveau : `:53-63`, `:70-73`, `:82-84`).
- [ ] `src/hooks/useCollapsibleGroups.ts` — persistance via `user_preferences` (`getPreference`/`setPreference`, async) au lieu de `localStorage` ; `storageKey: string | null` ; `options.defaultExpanded` ; sémantique « Set = écarts au défaut » ; `collapseAll(rows)`. **Ne pas** appeler `useProfile()`.
- [ ] Les 3 tables — corriger **les deux gates** (`collapsed` ET le bouton) : `isTopParent``isParent`.
- `ComparePeriodTable.tsx` (`:157` + `:184`), `BudgetVsActualTable.tsx` (`:119` + `:140`), `CategoryOverTimeTable.tsx` (`:96` + `:123`)
- Fournir `parentKeyOf` dans les 3 jeux d'accesseurs (`COMPARE_*:64`, `BVA_*:35`, `OVERTIME_*` dans `overTimeTableModel.ts:18`)
- [ ] `aria-level={depth + 1}` sur les lignes parentes.
- [ ] `overTimeTableModel.test.ts`**ÉTENDRE la fixture à 3 niveaux** (racine > parent intermédiaire > feuilles) et ajouter des assertions de cascade. (La v1 disait « mettre à jour » : vérifié, la fixture actuelle `:57-68` n'a que 2 niveaux, donc ses tests passent identiquement sous le nouvel algo — la tâche n'avait pas de cible.)
- [ ] Supprimer `src/components/reports/CategoryTable.tsx` (74 lignes, 0 import).
- [ ] CHANGELOG (EN + FR).
### Issue B — Adoption du repli sur la grille Budget [type:feature] (#289)
Dependances : Issue A (#288)
- [ ] `src/hooks/useBudget.ts`**lire et documenter l'invariant d'ordre**. Le tri (`:345-365`) est par *niveau*, pas DFS. Avec la remontée d'ancêtres, **aucun changement de tri n'est requis** — mais il faut le vérifier et l'acter par un test, car la v1 s'y était trompée.
- [ ] `BudgetTable.tsx` — brancher `useCollapsibleGroups` ; déclarer `BUDGET_COLLAPSE_ACCESSORS` + `BUDGET_EXPANDED_KEY` **inline au module scope**, comme `ComparePeriodTable:64` et `BudgetVsActualTable:35`. **Ne PAS créer `budgetTableModel.ts`** : `overTimeTableModel.ts` existe parce qu'il porte de la vraie logique (`groupOverTimeSections`), pas pour héberger trois lambdas.
- [ ] Chevron + `aria-expanded` sur **toute** ligne parente dans `renderRow` (`:168-205`) — les parents y sont aujourd'hui du texte pur.
- [ ] Bouton « Tout déplier / Tout replier » (réutiliser `reports.collapse.expandAll` / `.collapseAll`).
- [ ] **Remplacer le sous-total de section fait main** (`:301-311`) par `sumLeavesForType` de `budgetTableResults.ts:42` — code déjà testé, et cela *prouve* le critère « sous-totaux sur lignes brutes » au lieu de le promettre.
- [ ] **Corriger la collision `localStorage`** : `BudgetTable.tsx:21` et `BudgetVsActualTable.tsx:30` déclarent tous deux `STORAGE_KEY = "subtotals-position"`. Préfixer celle du budget → `budget-subtotals-position`.
- [ ] CHANGELOG (EN + FR).
### Issue C — Unifier la machine à états des 2 arbres de catégories [type:refactor] (#290)
Dependances : Issue A (#288). Indépendante de B.
Les trois implémentations de repli ont des **rendus** inconciliables (liste plate indentée vs récursion sur `children[]`) mais une **machine à états identique** (Set d'ids, toggle, expandAll, collapseAll, allExpanded, polarité, persistance). Après A, le hook est un **sur-ensemble strict** de ce que les deux arbres bricolent.
- [ ] `CategoryTree.tsx` (page Catégories, CRUD) — adopter `useCollapsibleGroups` pour **l'état seul** (`storageKey: null`, `defaultExpanded: true`), en **gardant** son rendu récursif et son drag-and-drop. Supprimer `collectExpandable` (`:195-204`) et son `Set` local.
- [ ] `CategoryTaxonomyTree.tsx` + `CategoriesStandardGuidePage.tsx` (guide read-only) — idem (`storageKey: null`, `defaultExpanded: false`).
- [ ] **Corriger le bug avéré** : `CategoriesStandardGuidePage:79` calcule `allExpanded = expanded.size > 0` — un seul nœud déplié bascule le bouton en « Tout replier ». Le `allExpanded` du hook est correct.
- [ ] Les deux arbres passent leurs nœuds **aplatis** à `expandAll` / `collapseAll` / `allExpanded` / `groupCount` (qui attendent un tableau).
- [ ] CHANGELOG (EN + FR) — uniquement pour le bug du bouton (le reste est interne).
### Ordre d'execution
```
Issue A (#288) → Issue B (#289)
Issue A (#288) → Issue C (#290)
```
B et C sont indépendantes entre elles et parallélisables — **mais** elles touchent toutes deux les CHANGELOG et les locales : voir les conflits partagés connus du repo.
## Fichiers concernes
| Fichier | Action | Issue | Raison |
|---------|--------|-------|--------|
| `src/utils/collapsibleRows.ts` | Modifier | A | Remontée d'ancêtres, `parentKeyOf`, `keyOf` préfixé |
| `src/utils/collapsibleRows.test.ts` | Réécrire | A | Les tests verrouillent le mono-niveau |
| `src/hooks/useCollapsibleGroups.ts` | Modifier | A | `user_preferences`, polarité, `storageKey` nullable |
| `src/services/userPreferenceService.ts` | Lire | A | `getPreference` / `setPreference` (déjà async, inchangés) |
| `src/components/reports/ComparePeriodTable.tsx` | Modifier | A | 2 gates + `parentKeyOf` + `aria-level` |
| `src/components/reports/BudgetVsActualTable.tsx` | Modifier | A | Idem |
| `src/components/reports/CategoryOverTimeTable.tsx` | Modifier | A | Idem |
| `src/components/reports/overTimeTableModel.ts` | Modifier | A | `parentKeyOf` dans les accesseurs |
| `src/components/reports/overTimeTableModel.test.ts` | Modifier | A | Étendre la fixture à 3 niveaux |
| `src/components/reports/CategoryTable.tsx` | **Supprimer** | A | Code mort (0 import) |
| `src/hooks/useBudget.ts` | Lire + tester | B | 3ᵉ builder — ordre par niveau, à acter par un test |
| `src/components/budget/BudgetTable.tsx` | Modifier | B | Repli + `sumLeavesForType` + clé `budget-subtotals-position` |
| `src/components/budget/budgetTableResults.ts` | Lire | B | `sumLeavesForType:42` à réutiliser |
| `src/components/categories/CategoryTree.tsx` | Modifier | C | Adopter le hook pour l'état |
| `src/components/categories/CategoryTaxonomyTree.tsx` | Modifier | C | Idem |
| `src/pages/CategoriesStandardGuidePage.tsx` | Modifier | C | Bug `allExpanded = expanded.size > 0` |
| `src/i18n/locales/{fr,en}.json` | Modifier | A, B | Clés `reports.collapse.*` existent déjà ; éventuel aria-label |
| `CHANGELOG.md`, `CHANGELOG.fr.md` | Modifier | A, B, C | Une entrée par issue |
## Plan de tests
Le repo n'a **ni jsdom ni testing-library** : aucun composant React n'est rendable. La logique vit dans les modules purs.
### Tests unitaires — `collapsibleRows.test.ts` (réécrit)
- Set vide ⇒ seules les racines sont visibles (défaut replié, tous niveaux).
- Déplier une racine révèle **uniquement ses enfants directs**, pas ses petits-enfants.
- Déplier racine **et** intermédiaire révèle les feuilles.
- Déplier un intermédiaire dont la racine est repliée ne révèle **rien** (un ancêtre replié gagne toujours).
- **Ordre par niveau** (fixture BFS, celle de la grille Budget) : le masquage est **identique** à celui d'une fixture DFS. *C'est le test qui aurait tué la v1.*
- **Sous-totaux en bas** (`reorderRows`) : masquage inchangé.
- **Feuille « (direct) »** : parent replié ⇒ elle est masquée ; parent déplié ⇒ visible ; elle ne porte jamais de chevron.
- **`parent_id` corrompu (cycle)** : le garde `MAX_TREE_DEPTH` empêche la boucle infinie.
- **Ancêtre absent de la section** (enfant de type différent du parent) : la ligne reste visible, pas d'orphelin masqué.
- `collapsibleKeys` liste **tous** les parents, intermédiaires compris.
### Tests unitaires — polarité du hook
La logique de polarité (`Set = écarts au défaut`) doit être extraite en helper **pur** pour être testable (le hook lui-même ne l'est pas) : `isCollapsedFor(flipped, key, defaultExpanded)`.
### Tests d'integration
- `overTimeTableModel.test.ts` — fixture **étendue à 3 niveaux** + assertions de cascade. **Conserver** l'assertion « le repli est purement visuel ».
- `budgetTableResults``sumLeavesForType` couvre déjà les sous-totaux de section : le brancher *prouve* l'invariant.
### Tests de regression (à figer AVANT modification)
- **Le repli est purement visuel** : sous-totaux, « Résultat avant transferts » et « Résultat net » se calculent sur les lignes **brutes**, jamais sur les visibles — sur les 3 rapports **et** sur la grille Budget.
- **Grille Budget** : replier un parent n'altère aucune valeur saisie ; les totaux de section sont identiques, repliés ou non.
- **Ordre des lignes de `useBudget`** : test actant que l'ordre est par niveau (documente l'invariant que la v1 avait supposé à tort).
## Criteres d'acceptation
### Issue A (socle + 3 rapports)
- [ ] Toute catégorie parente est repliable à n'importe quel niveau (les 41 intermédiaires incluses)
- [ ] À l'ouverture, seules les racines sont visibles
- [ ] Déplier un parent révèle ses enfants directs, eux-mêmes repliés
- [ ] Un descendant n'est visible que si **tous** ses ancêtres sont dépliés
- [ ] Le masquage est **identique** que les lignes soient en DFS ou par niveau
- [ ] Un parent intermédiaire replié affiche un chevron **fermé** et `aria-expanded={false}`
- [ ] « Tout déplier » ouvre tous les niveaux d'un coup
- [ ] L'état est persisté dans `user_preferences` (donc isolé par profil, et détruit avec lui)
- [ ] Sous-totaux et lignes de résultat rigoureusement inchangés, replié ou déplié
- [ ] Les lignes parentes portent `aria-level`
### Issue B (grille Budget)
- [ ] Toute catégorie parente de la grille est repliable, à n'importe quel niveau
- [ ] La grille s'ouvre entièrement repliée ; « Tout déplier » est visible d'emblée
- [ ] Aucune valeur de budget n'est altérée par un repli ; totaux de section et lignes de résultat inchangés
- [ ] Les sous-totaux de section passent par `sumLeavesForType` (plus de calcul fait main)
- [ ] La position des sous-totaux de la grille est **indépendante** de celle du rapport réel-vs-budget
### Issue C (unification)
- [ ] `CategoryTree` et `CategoryTaxonomyTree` ne portent plus leur propre `Set` d'état
- [ ] La page Catégories s'ouvre toujours **dépliée** ; le guide toujours **replié** (comportements préservés)
- [ ] Le drag-and-drop de la page Catégories fonctionne comme avant
- [ ] Sur le guide, déplier **un** nœud ne bascule plus le bouton en « Tout replier »
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| **Chaîne `parent_id` cyclique** (corruption DB) | Garde `hops < MAX_TREE_DEPTH` dans la remontée. Test dédié. |
| **Ancêtre absent du tableau de lignes** (enfant d'un type différent de son parent ⇒ autre section) | `parents.get(key) === undefined` ⇒ la ligne est **visible**. Choix explicite : mieux vaut une ligne orpheline visible qu'une ligne masquée par un ancêtre introuvable. Test dédié. |
| **Hydratation asynchrone** depuis `user_preferences` | Sans flash : le défaut « tout replié » *est* l'état initial. L'hydratation ne peut que déplier. Sur la page Catégories (Issue C, défaut déplié), `storageKey: null` ⇒ aucune hydratation, donc aucun flash. |
| **Changement de profil** | La clé vit dans la base du profil : après bascule, `getPreference` lit la nouvelle base. Vérifier que les composants de rapport se remontent (ou se ré-hydratent) au switch. |
| **Grille Budget : 1re ouverture sans champ de saisie** | Décision assumée et tracée. « Tout déplier » doit être visible d'emblée. À réévaluer si l'usage la contredit. |
| **`depth` optionnel** sur `BudgetYearRow` / `BudgetVsActualRow` avec des fallbacks dérivés (`row.depth ?? (…? 1 : 0)`) | **Neutralisé** : `depth` ne pilote plus la visibilité, seulement l'indentation et `aria-level`. Une valeur fausse dégrade l'affichage, elle ne masque plus les mauvaises lignes. |
| **Perf** | ~150 lignes × profondeur ≤ 3 ⇒ ~450 remontées par rendu. Négligeable ; mémoïsation inutile. |
## Revision — Synthese
> Date: 2026-07-13 | Experts: Securite, Architecture, Technique
### Verdict
🟡 **AMELIORATIONS INTEGREES** — la v1 portait un défaut fatal (algorithme adjacency-dépendant sur des lignes qui ne sont pas ordonnées comme supposé). Les trois critiques ont été résolues **dans le plan**, pas annotées : le design a changé.
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|-----|-----|-----|-------------|
| Securite | 1 | 1 | 1 | Grille Budget en ordre par niveau ; résidu `localStorage` survivant à `deleteProfile` ; `depth` optionnel |
| Architecture | 1 | 5 | 1 | Même défaut d'ordre ; remontée d'ancêtres ; triplication ; `keyOf` non injectif ; `budgetTableModel` inutile |
| Technique | 2 | 3 | 2 | Même défaut d'ordre ; **3ᵉ builder (`useBudget`) absent du plan** ; flag `collapsed` gaté sur `isTopParent` ; fixture overTime ne verrouille rien |
### Decisions tranchees (2026-07-13)
1. **Visibilité par remontée de `parent_id`**, et non par adjacence — résout d'un coup l'ordre par niveau de la grille, le tri par type qui sépare parent et enfant, la feuille « (direct) », et la contrainte `visible()`-avant-`reorderRows`.
2. **Persistance dans `user_preferences`** (base du profil) et non `localStorage` — zéro résidu après suppression d'un profil, scoping gratuit, aucun helper de préfixe. Motivé par le principe privacy-first.
3. **Unification de la machine à états** des deux arbres de catégories (Issue C) — les rendus restent distincts, l'état est partagé ; corrige au passage le bug `allExpanded = expanded.size > 0` du guide.
### Corrections factuelles apportees a la v1
- `useBudget.ts` (3ᵉ builder) était **absent** du plan et des fichiers concernés → ajouté.
- « Aucun service touché » était **faux** → corrigé.
- Le flag `collapsed = isTopParent && …` (3 tables) était **oublié** → tâche explicite.
- « `overTimeTableModel.test.ts` verrouille le mono-niveau » était **faux** (fixture à 2 niveaux) → la tâche devient *étendre* la fixture, pas la mettre à jour.
- `budgetTableModel.ts` : abandonné (les 2 autres tables déclarent leurs accesseurs inline ; la vraie logique extractible — `sumLeavesForType` — existait déjà).
- Le garde `isParent`, décrit comme « structurel », redevient **cosmétique** grâce à `keyOf` injectif (`p:`).

View file

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

View file

@ -1,27 +1,22 @@
import { useMemo, useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowRight, ChevronsDownUp, ChevronsUpDown, Search } from "lucide-react"; import { ArrowRight, ChevronsDownUp, ChevronsUpDown, Search } from "lucide-react";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import CategoryTaxonomyTree, { import CategoryTaxonomyTree from "../categories/CategoryTaxonomyTree";
TAXONOMY_COLLAPSE_ACCESSORS,
} from "../categories/CategoryTaxonomyTree";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService"; import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
interface StepDiscoverProps { interface StepDiscoverProps {
onNext: () => void; onNext: () => void;
} }
// Flattens the taxonomy so the hook's bulk ops (expand/collapse all, allExpanded) function collectAllIds(nodes: TaxonomyNode[]): number[] {
// can walk every parent at any depth. const ids: number[] = [];
function flattenNodes(nodes: TaxonomyNode[]): TaxonomyNode[] {
const flat: TaxonomyNode[] = [];
const walk = (n: TaxonomyNode) => { const walk = (n: TaxonomyNode) => {
flat.push(n); ids.push(n.id);
n.children.forEach(walk); n.children.forEach(walk);
}; };
nodes.forEach(walk); nodes.forEach(walk);
return flat; return ids;
} }
function countNodes(nodes: TaxonomyNode[]): { function countNodes(nodes: TaxonomyNode[]): {
@ -57,23 +52,25 @@ export default function StepDiscover({ onNext }: StepDiscoverProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy(); const { taxonomy } = useCategoryTaxonomy();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
// State machine only (issue #290): in-memory (storageKey null), collapsed by
// default — same as the standalone guide page it shares CategoryTaxonomyTree with.
const groups = useCollapsibleGroups<TaxonomyNode>(null, TAXONOMY_COLLAPSE_ACCESSORS, {
defaultExpanded: false,
});
const counts = countNodes(taxonomy.roots); const counts = countNodes(taxonomy.roots);
const total = counts.roots + counts.subcategories + counts.leaves; const total = counts.roots + counts.subcategories + counts.leaves;
const flatNodes = useMemo(() => flattenNodes(taxonomy.roots), [taxonomy.roots]); const toggleNode = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExpandAll = () => groups.expandAll(flatNodes); const handleExpandAll = () => {
const handleCollapseAll = () => groups.collapseAll(flatNodes); setExpanded(new Set(collectAllIds(taxonomy.roots)));
// Correct "all expanded" test (issue #290): every group must be open, fixing the };
// old expanded.size > 0 bug that flipped the button after a single expand. const handleCollapseAll = () => setExpanded(new Set());
const allExpanded = groups.allExpanded(flatNodes); const allExpanded = expanded.size > 0;
return ( return (
<section className="space-y-6"> <section className="space-y-6">
@ -149,8 +146,8 @@ export default function StepDiscover({ onNext }: StepDiscoverProps) {
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<CategoryTaxonomyTree <CategoryTaxonomyTree
nodes={taxonomy.roots} nodes={taxonomy.roots}
isCollapsed={groups.isCollapsed} expanded={expanded}
onToggle={groups.toggle} onToggle={toggleNode}
searchQuery={search} searchQuery={search}
/> />
</div> </div>

View file

@ -2,49 +2,33 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react"; import { ChevronRight, ChevronDown } from "lucide-react";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService"; import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
/**
* Collapse-state accessors for the taxonomy tree, shared by every consumer that
* drives it through `useCollapsibleGroups` (the guide page and the migration
* wizard's Discover step) issue #290. Only keyOf/isParent are consulted: the
* tree renders recursively and gates each node on `isCollapsed(node)`, so it never
* calls `visibleRows`; parentKeyOf/depthOf are unused stubs (TaxonomyNode carries
* no parent_id).
*/
export const TAXONOMY_COLLAPSE_ACCESSORS: CollapseAccessors<TaxonomyNode> = {
keyOf: (node) => `p:${node.id}`,
parentKeyOf: () => null,
isParent: (node) => node.children.length > 0,
depthOf: () => 0,
};
interface CategoryTaxonomyTreeProps { interface CategoryTaxonomyTreeProps {
nodes: TaxonomyNode[]; nodes: TaxonomyNode[];
isCollapsed: (node: TaxonomyNode) => boolean; expanded: Set<number>;
onToggle: (node: TaxonomyNode) => void; onToggle: (id: number) => void;
searchQuery: string; searchQuery: string;
} }
interface NodeRowProps { interface NodeRowProps {
node: TaxonomyNode; node: TaxonomyNode;
depth: number; depth: number;
isCollapsed: (node: TaxonomyNode) => boolean; expanded: Set<number>;
onToggle: (node: TaxonomyNode) => void; onToggle: (id: number) => void;
visibleIds: Set<number> | null; visibleIds: Set<number> | null;
} }
function NodeRow({ function NodeRow({
node, node,
depth, depth,
isCollapsed, expanded,
onToggle, onToggle,
visibleIds, visibleIds,
}: NodeRowProps) { }: NodeRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const label = t(node.i18n_key, { defaultValue: node.name }); const label = t(node.i18n_key, { defaultValue: node.name });
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = !isCollapsed(node); const isExpanded = expanded.has(node.id);
// Filter children by visibility set (search mode) if provided. // Filter children by visibility set (search mode) if provided.
const visibleChildren = useMemo(() => { const visibleChildren = useMemo(() => {
@ -79,7 +63,7 @@ function NodeRow({
{hasChildren ? ( {hasChildren ? (
<button <button
type="button" type="button"
onClick={() => onToggle(node)} onClick={() => onToggle(node.id)}
aria-label={ aria-label={
isExpanded isExpanded
? t("categoriesSeed.guidePage.collapseAll") ? t("categoriesSeed.guidePage.collapseAll")
@ -130,7 +114,7 @@ function NodeRow({
key={child.id} key={child.id}
node={child} node={child}
depth={depth + 1} depth={depth + 1}
isCollapsed={isCollapsed} expanded={expanded}
onToggle={onToggle} onToggle={onToggle}
visibleIds={visibleIds} visibleIds={visibleIds}
/> />
@ -188,7 +172,7 @@ export function normalize(s: string): string {
export default function CategoryTaxonomyTree({ export default function CategoryTaxonomyTree({
nodes, nodes,
isCollapsed, expanded,
onToggle, onToggle,
searchQuery, searchQuery,
}: CategoryTaxonomyTreeProps) { }: CategoryTaxonomyTreeProps) {
@ -220,7 +204,7 @@ export default function CategoryTaxonomyTree({
key={root.id} key={root.id}
node={root} node={root}
depth={0} depth={0}
isCollapsed={isCollapsed} expanded={expanded}
onToggle={onToggle} onToggle={onToggle}
visibleIds={visibleIds} visibleIds={visibleIds}
/> />

View file

@ -18,8 +18,6 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import type { CategoryTreeNode } from "../../shared/types"; import type { CategoryTreeNode } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
interface FlatItem { interface FlatItem {
id: number; id: number;
@ -42,28 +40,14 @@ function getSubtreeDepth(node: CategoryTreeNode): number {
return 1 + Math.max(...node.children.map(getSubtreeDepth)); return 1 + Math.max(...node.children.map(getSubtreeDepth));
} }
// State machine shared with useCollapsibleGroups (issue #290). Only keyOf/isParent function flattenTree(tree: CategoryTreeNode[], expandedSet: Set<number>): FlatItem[] {
// are consulted here: this tree renders recursively and gates each node on
// isCollapsed(node), so it never calls visibleRows — parentKeyOf/depthOf exist to
// satisfy the accessor contract but are not read (depth is computed in flattenTree).
const CATEGORY_TREE_ACCESSORS: CollapseAccessors<CategoryTreeNode> = {
keyOf: (node) => `p:${node.id}`,
parentKeyOf: (node) => (node.parent_id != null ? `p:${node.parent_id}` : null),
isParent: (node) => node.children.length > 0,
depthOf: () => 0,
};
function flattenTree(
tree: CategoryTreeNode[],
isExpanded: (node: CategoryTreeNode) => boolean,
): FlatItem[] {
const items: FlatItem[] = []; const items: FlatItem[] = [];
function recurse(nodes: CategoryTreeNode[], depth: number, parentId: number | null) { function recurse(nodes: CategoryTreeNode[], depth: number, parentId: number | null) {
for (const node of nodes) { for (const node of nodes) {
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const expanded = hasChildren && isExpanded(node); const isExpanded = expandedSet.has(node.id);
items.push({ id: node.id, node, depth, parentId, isExpanded: expanded, hasChildren }); items.push({ id: node.id, node, depth, parentId, isExpanded, hasChildren });
if (expanded) { if (isExpanded && hasChildren) {
recurse(node.children, depth + 1, node.id); recurse(node.children, depth + 1, node.id);
} }
} }
@ -169,7 +153,7 @@ function SortableTreeRow({
item: FlatItem; item: FlatItem;
selectedId: number | null; selectedId: number | null;
onSelect: (id: number) => void; onSelect: (id: number) => void;
onToggle: (node: CategoryTreeNode) => void; onToggle: (id: number) => void;
isDragActive: boolean; isDragActive: boolean;
}) { }) {
const { const {
@ -196,7 +180,7 @@ function SortableTreeRow({
selectedId={isDragActive ? null : selectedId} selectedId={isDragActive ? null : selectedId}
onSelect={onSelect} onSelect={onSelect}
expanded={item.isExpanded} expanded={item.isExpanded}
onToggle={() => onToggle(item.node)} onToggle={() => onToggle(item.id)}
hasChildren={item.hasChildren} hasChildren={item.hasChildren}
dragHandleProps={listeners} dragHandleProps={listeners}
isDragging={isDragging} isDragging={isDragging}
@ -206,20 +190,23 @@ function SortableTreeRow({
} }
export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) { export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) {
// State machine only (issue #290): in-memory (storageKey null), expanded by const [expanded, setExpanded] = useState<Set<number>>(() => {
// default (defaultExpanded true) so every parent opens with no seeding — the const ids = new Set<number>();
// previous collectExpandable + local Set behaviour. Render + DnD stay untouched. function collectExpandable(nodes: CategoryTreeNode[]) {
const { isCollapsed, toggle } = useCollapsibleGroups<CategoryTreeNode>( for (const node of nodes) {
null, if (node.children.length > 0) {
CATEGORY_TREE_ACCESSORS, ids.add(node.id);
{ defaultExpanded: true }, collectExpandable(node.children);
); }
}
}
collectExpandable(tree);
return ids;
});
const [activeId, setActiveId] = useState<number | null>(null); const [activeId, setActiveId] = useState<number | null>(null);
const flatItems = useMemo( // Update expanded set when tree changes (new parents appear)
() => flattenTree(tree, (node) => !isCollapsed(node)), const flatItems = useMemo(() => flattenTree(tree, expanded), [tree, expanded]);
[tree, isCollapsed],
);
const activeItem = useMemo( const activeItem = useMemo(
() => (activeId !== null ? flatItems.find((i) => i.id === activeId) ?? null : null), () => (activeId !== null ? flatItems.find((i) => i.id === activeId) ?? null : null),
@ -232,6 +219,15 @@ export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategor
}) })
); );
const toggle = useCallback((id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as number); setActiveId(event.active.id as number);
}, []); }, []);

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -914,7 +914,6 @@
"Split annual amount evenly across 12 months", "Split annual amount evenly across 12 months",
"Budget templates to save and apply configurations", "Budget templates to save and apply configurations",
"Parent category subtotals", "Parent category subtotals",
"Collapse/expand the hierarchy at every level: each parent category has its own chevron, the grid opens fully collapsed, and an \"Expand all / Collapse all\" button opens or closes every level at once — remembered per profile",
"Column headers stay fixed when scrolling vertically" "Column headers stay fixed when scrolling vertically"
], ],
"steps": [ "steps": [
@ -927,8 +926,7 @@
"tips": [ "tips": [
"The Annual column auto-sums all 12 months — a warning appears if monthly totals don't match", "The Annual column auto-sums all 12 months — a warning appears if monthly totals don't match",
"Templates can be applied to specific months or all 12 at once", "Templates can be applied to specific months or all 12 at once",
"Parent categories show subtotals aggregated from their children", "Parent categories show subtotals aggregated from their children"
"Collapsing a category is purely visual: subtotals and annual totals are always computed over all rows, never just the visible ones — no amount you typed is ever lost"
] ]
}, },
"reports": { "reports": {
@ -943,8 +941,7 @@
"Category Zoom: single-category drill-down with donut, monthly evolution, and filterable transaction table; auto-rollup of subcategories", "Category Zoom: single-category drill-down with donut, monthly evolution, and filterable transaction table; auto-rollup of subcategories",
"Contextual keyword editing: right-click a transaction row to add its description as a keyword with a live preview of the matches", "Contextual keyword editing: right-click a transaction row to add its description as a keyword with a live preview of the matches",
"SVG patterns (lines, dots, crosshatch) to distinguish categories", "SVG patterns (lines, dots, crosshatch) to distinguish categories",
"View mode preference (chart vs. table) persisted per report section", "View mode preference (chart vs. table) persisted per report section"
"Collapse the category hierarchy at every level on the Compare reports and the by-category Trends table: a chevron per parent category, opens fully collapsed, an \"Expand all / Collapse all\" button; purely visual (subtotals and results unchanged), remembered per profile"
], ],
"steps": [ "steps": [
"Open /reports to see the highlights panel and four navigation cards", "Open /reports to see the highlights panel and four navigation cards",
@ -960,8 +957,7 @@
"Keywords must be 264 characters long", "Keywords must be 264 characters long",
"The Category Zoom is protected against malformed category trees: a parent_id cycle cannot freeze the app", "The Category Zoom is protected against malformed category trees: a parent_id cycle cannot freeze the app",
"On /reports/cartes in YTD mode, the MoM delta for January is always \"—\" (no prior YTD window inside the same year), and the savings rate stays \"—\" when YTD income is zero", "On /reports/cartes in YTD mode, the MoM delta for January is always \"—\" (no prior YTD window inside the same year), and the savings rate stays \"—\" when YTD income is zero",
"Seasonality, top movers, and budget adherence stay monthly even when the toggle is set to YTD — only the 4 KPI numbers change", "Seasonality, top movers, and budget adherence stay monthly even when the toggle is set to YTD — only the 4 KPI numbers change"
"Your category collapse choices (Compare, Trends, Budget) are remembered per profile: stored in the profile's own database, they are destroyed with it"
] ]
}, },
"balance": { "balance": {

View file

@ -914,7 +914,6 @@
"Répartition égale du montant annuel sur 12 mois", "Répartition égale du montant annuel sur 12 mois",
"Modèles de budget pour sauvegarder et appliquer des configurations", "Modèles de budget pour sauvegarder et appliquer des configurations",
"Sous-totaux par catégorie parente", "Sous-totaux par catégorie parente",
"Repli / dépli de la hiérarchie à chaque niveau : chaque catégorie parente a son chevron, la grille s'ouvre entièrement repliée, et un bouton « Tout déplier / Tout replier » ouvre ou ferme tous les niveaux d'un coup — mémorisé par profil",
"En-têtes de colonnes fixes au défilement vertical" "En-têtes de colonnes fixes au défilement vertical"
], ],
"steps": [ "steps": [
@ -927,8 +926,7 @@
"tips": [ "tips": [
"La colonne Annuel additionne automatiquement les 12 mois — un avertissement apparaît si les totaux mensuels ne correspondent pas", "La colonne Annuel additionne automatiquement les 12 mois — un avertissement apparaît si les totaux mensuels ne correspondent pas",
"Les modèles peuvent être appliqués à des mois spécifiques ou aux 12 mois d'un coup", "Les modèles peuvent être appliqués à des mois spécifiques ou aux 12 mois d'un coup",
"Les catégories parentes affichent les sous-totaux agrégés de leurs enfants", "Les catégories parentes affichent les sous-totaux agrégés de leurs enfants"
"Replier une catégorie est purement visuel : les sous-totaux et les totaux annuels sont toujours calculés sur toutes les lignes, jamais sur les seules lignes visibles — aucun montant saisi n'est perdu"
] ]
}, },
"reports": { "reports": {
@ -943,8 +941,7 @@
"Zoom catégorie : analyse d'une seule catégorie avec donut, évolution mensuelle et tableau de transactions filtrable ; rollup automatique des sous-catégories", "Zoom catégorie : analyse d'une seule catégorie avec donut, évolution mensuelle et tableau de transactions filtrable ; rollup automatique des sous-catégories",
"Édition contextuelle des mots-clés : clic droit sur une ligne de transaction pour ajouter sa description comme mot-clé avec prévisualisation en direct des matches", "Édition contextuelle des mots-clés : clic droit sur une ligne de transaction pour ajouter sa description comme mot-clé avec prévisualisation en direct des matches",
"Motifs SVG (lignes, points, hachures) pour distinguer les catégories", "Motifs SVG (lignes, points, hachures) pour distinguer les catégories",
"Préférence chart/table mémorisée par section de rapport", "Préférence chart/table mémorisée par section de rapport"
"Repli de la hiérarchie à chaque niveau sur les Comparables et la tendance Par catégorie : chevron par catégorie parente, ouverture entièrement repliée, bouton « Tout déplier / Tout replier » ; purement visuel (sous-totaux et résultats inchangés), mémorisé par profil"
], ],
"steps": [ "steps": [
"Ouvrez /reports pour voir le panneau de faits saillants et les quatre cartes de navigation", "Ouvrez /reports pour voir le panneau de faits saillants et les quatre cartes de navigation",
@ -960,8 +957,7 @@
"Les mots-clés doivent faire entre 2 et 64 caractères", "Les mots-clés doivent faire entre 2 et 64 caractères",
"Le Zoom catégorie est protégé contre les arborescences malformées : un cycle parent_id ne peut pas figer l'app", "Le Zoom catégorie est protégé contre les arborescences malformées : un cycle parent_id ne peut pas figer l'app",
"Sur /reports/cartes en mode YTD, le delta MoM du mois de janvier est toujours « — » (pas de fenêtre YTD antérieure dans la même année), et le taux d'épargne reste « — » quand les revenus YTD sont à zéro", "Sur /reports/cartes en mode YTD, le delta MoM du mois de janvier est toujours « — » (pas de fenêtre YTD antérieure dans la même année), et le taux d'épargne reste « — » quand les revenus YTD sont à zéro",
"La saisonnalité, les top mouvements et l'adhésion budgétaire restent mensuels même quand le toggle est sur YTD — seuls les 4 chiffres KPI changent", "La saisonnalité, les top mouvements et l'adhésion budgétaire restent mensuels même quand le toggle est sur YTD — seuls les 4 chiffres KPI changent"
"Vos choix de repli des catégories (Comparables, Tendances, Budget) sont mémorisés par profil : stockés dans la base du profil, ils disparaissent avec lui"
] ]
}, },
"balance": { "balance": {

View file

@ -3,11 +3,8 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft, Search, Printer, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import { ArrowLeft, Search, Printer, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { useCategoryTaxonomy } from "../hooks/useCategoryTaxonomy"; import { useCategoryTaxonomy } from "../hooks/useCategoryTaxonomy";
import CategoryTaxonomyTree, { import CategoryTaxonomyTree from "../components/categories/CategoryTaxonomyTree";
TAXONOMY_COLLAPSE_ACCESSORS,
} from "../components/categories/CategoryTaxonomyTree";
import type { TaxonomyNode } from "../services/categoryTaxonomyService"; import type { TaxonomyNode } from "../services/categoryTaxonomyService";
import { useCollapsibleGroups } from "../hooks/useCollapsibleGroups";
function countNodes(nodes: TaxonomyNode[]): { function countNodes(nodes: TaxonomyNode[]): {
roots: number; roots: number;
@ -35,47 +32,51 @@ function countNodes(nodes: TaxonomyNode[]): {
return { roots, subcategories, leaves }; return { roots, subcategories, leaves };
} }
// Flattens the taxonomy to a single array so the hook's bulk ops function collectAllIds(nodes: TaxonomyNode[]): number[] {
// (expandAll/collapseAll/allExpanded/groupCount) can walk every parent at any depth. const ids: number[] = [];
function flattenNodes(nodes: TaxonomyNode[]): TaxonomyNode[] {
const flat: TaxonomyNode[] = [];
const walk = (n: TaxonomyNode) => { const walk = (n: TaxonomyNode) => {
flat.push(n); ids.push(n.id);
n.children.forEach(walk); n.children.forEach(walk);
}; };
nodes.forEach(walk); nodes.forEach(walk);
return flat; return ids;
} }
export default function CategoriesStandardGuidePage() { export default function CategoriesStandardGuidePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy(); const { taxonomy } = useCategoryTaxonomy();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<number>>(() => {
// State machine only (issue #290): in-memory (storageKey null), collapsed by // Start with roots collapsed (user can expand as needed); counter and search still work.
// default so the guide opens on roots — the previous local Set behaviour. return new Set<number>();
const groups = useCollapsibleGroups<TaxonomyNode>(null, TAXONOMY_COLLAPSE_ACCESSORS, {
defaultExpanded: false,
}); });
const counts = useMemo(() => countNodes(taxonomy.roots), [taxonomy.roots]); const counts = useMemo(() => countNodes(taxonomy.roots), [taxonomy.roots]);
const total = counts.roots + counts.subcategories + counts.leaves; const total = counts.roots + counts.subcategories + counts.leaves;
// Flattened nodes for the bulk ops (expand/collapse all, allExpanded). const toggleNode = (id: number) => {
const flatNodes = useMemo(() => flattenNodes(taxonomy.roots), [taxonomy.roots]); setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExpandAll = () => groups.expandAll(flatNodes); const handleExpandAll = () => {
const handleCollapseAll = () => groups.collapseAll(flatNodes); setExpanded(new Set(collectAllIds(taxonomy.roots)));
};
const handleCollapseAll = () => {
setExpanded(new Set());
};
const handlePrint = () => { const handlePrint = () => {
// window.print() opens the browser print dialog; @media print rules strip chrome. // window.print() opens the browser print dialog; @media print rules strip chrome.
window.print(); window.print();
}; };
// Correct "all expanded" test (issue #290): every collapsible group must be open, const allExpanded = expanded.size > 0;
// not merely "at least one node open" (the old expanded.size > 0 bug, which
// flipped the button to "Collapse all" after a single node was expanded).
const allExpanded = groups.allExpanded(flatNodes);
return ( return (
<div className="p-6 max-w-4xl mx-auto space-y-6"> <div className="p-6 max-w-4xl mx-auto space-y-6">
@ -176,8 +177,8 @@ export default function CategoriesStandardGuidePage() {
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 taxonomy-tree-print"> <section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 taxonomy-tree-print">
<CategoryTaxonomyTree <CategoryTaxonomyTree
nodes={taxonomy.roots} nodes={taxonomy.roots}
isCollapsed={groups.isCollapsed} expanded={expanded}
onToggle={groups.toggle} onToggle={toggleNode}
searchQuery={search} searchQuery={search}
/> />
</section> </section>

View file

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

View file

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