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>
9.6 KiB
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'huidepth === 0en 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é
localStoragesubtotals-position, partagée entreBudgetVsActualTable.tsx:30etBudgetTable.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).
- collision de clé
aria-levelsur 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-expandedsur 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-cycleMAX_TREE_DEPTH = 5est 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) etbuildSubGroup(budgetService.ts:351-374) sont récursifs à profondeur arbitraire ; chaque ligne portedepth/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/groupCountreçoivent les lignes complètes (rows/data.tree), le filtrevisible()étant appliqué séparément au rendu. GénéralisercollapsibleKeyspour 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-63et:82-84affirment 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 | 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 | 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. |