Simpl-Resultat/spec-decisions-collapse-multi-niveaux.md
le king fu 5a3d87b31f docs(collapse): ADR 0016 per-profile UI state + guide, architecture, i18n
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>
2026-07-18 14:43:24 -04:00

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 soclecollapsibleRows.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érarchiquesComparePeriodTable (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 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.