Document the final multi-level category collapse behavior (shipped in #288/#289/#290) and the structural decision to persist per-profile UI state in user_preferences rather than localStorage. - ADR 0016 (accepted): profile-specific UI state (category collapse) lives in the profile's own SQLite user_preferences table, not localStorage. deleteProfile drops the .db but purges no localStorage, so a per-profile localStorage key would be a residue surviving profile deletion, leaking which categories a (possibly PIN-protected) profile explored. States the boundary: profile-specific -> user_preferences; machine-global (theme, subtotals position, Cartes period mode) -> localStorage. - guide-utilisateur.md sections 8 (Budget) + 9 (Reports): multi-level collapse, collapsed-by-default, "Expand all / Collapse all" button, memory per profile. - docs.* i18n keys (fr + en): mirror the guide additions in the in-app help page (docs.budget + docs.reports features/tips). - architecture.md: user_preferences line now names the 4 collapse keys, useCollapsibleGroups cross-cutting hook note, ADR index row. No DB migration, no behavior change (docs only). Build clean, 828 vitest. Resolves #291 Generated autonomously by /autopilot run of 2026-07-15 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
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
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-specont 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.
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 :
- L'ordre par niveau de la grille Budget (
useBudget.ts:361trie pardepthcroissant) — l'algorithme est désormais indépendant de l'ordre des lignes. - 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 (
CategoryFormautorise 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. - 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 gardeisParentredevient cosmétique (« pas de chevron sur une feuille »), il n'est plus load-bearing. - La contrainte «
visible()avantreorderRows» disparaît —reorderRowspeut 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
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 :
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 :
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éécrirevisibleRowsen remontée d'ancêtres (code ci-dessus) ; ajouterparentKeyOfàCollapseAccessors;collapsibleKeysretourne tous les parents ;keyOfpré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 viauser_preferences(getPreference/setPreference, async) au lieu delocalStorage;storageKey: string | null;options.defaultExpanded; sémantique « Set = écarts au défaut » ;collapseAll(rows). Ne pas appeleruseProfile().- Les 3 tables — corriger les deux gates (
collapsedET le bouton) :isTopParent→isParent.ComparePeriodTable.tsx(:157+:184),BudgetVsActualTable.tsx(:119+:140),CategoryOverTimeTable.tsx(:96+:123)- Fournir
parentKeyOfdans les 3 jeux d'accesseurs (COMPARE_*:64,BVA_*:35,OVERTIME_*dansoverTimeTableModel.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-68n'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— brancheruseCollapsibleGroups; déclarerBUDGET_COLLAPSE_ACCESSORS+BUDGET_EXPANDED_KEYinline au module scope, commeComparePeriodTable:64etBudgetVsActualTable:35. Ne PAS créerbudgetTableModel.ts:overTimeTableModel.tsexiste parce qu'il porte de la vraie logique (groupOverTimeSections), pas pour héberger trois lambdas.- Chevron +
aria-expandedsur toute ligne parente dansrenderRow(: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) parsumLeavesForTypedebudgetTableResults.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:21etBudgetVsActualTable.tsx:30déclarent tous deuxSTORAGE_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) — adopteruseCollapsibleGroupspour l'état seul (storageKey: null,defaultExpanded: true), en gardant son rendu récursif et son drag-and-drop. SupprimercollectExpandable(:195-204) et sonSetlocal.CategoryTaxonomyTree.tsx+CategoriesStandardGuidePage.tsx(guide read-only) — idem (storageKey: null,defaultExpanded: false).- Corriger le bug avéré :
CategoriesStandardGuidePage:79calculeallExpanded = expanded.size > 0— un seul nœud déplié bascule le bouton en « Tout replier ». LeallExpandeddu 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_idcorrompu (cycle) : le gardeMAX_TREE_DEPTHempê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é.
collapsibleKeysliste 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—sumLeavesForTypecouvre 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)
CategoryTreeetCategoryTaxonomyTreene portent plus leur propreSetd'é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)
- 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 contraintevisible()-avant-reorderRows. - Persistance dans
user_preferences(base du profil) et nonlocalStorage— zéro résidu après suppression d'un profil, scoping gratuit, aucun helper de préfixe. Motivé par le principe privacy-first. - 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 > 0du 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.tsverrouille 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 àkeyOfinjectif (p:).