# 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 { keyOf: (row: T) => string; // 'p:' — seuls les parents sont keyés parentKeyOf: (row: T) => string | null; // NOUVEAU — 'p:', ou null à la racine isParent: (row: T) => boolean; depthOf: (row: T) => number; // CONSERVÉ, mais pour l'indentation + aria-level UNIQUEMENT } export function visibleRows(rows, acc, isCollapsed): T[] { // N'indexer QUE les parents : une feuille « (direct) » porte la clé de son parent. const parents = new Map(); 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(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( storageKey: string | null, // null ⇒ aucune persistance (état React pur) acc: CollapseAccessors, options?: { defaultExpanded?: boolean }, // défaut false ): CollapsibleGroups ``` **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 ? : } // (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:`).