Simpl-Resultat/spec-plan-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

22 KiB
Raw Blame History

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-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.

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

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éé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.tsréé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) : isTopParentisParent.
    • 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.tslire 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 ».
  • budgetTableResultssumLeavesForType 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:).