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

```ts
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 :

```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 ? <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.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:`).
