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>
297 lines
22 KiB
Markdown
297 lines
22 KiB
Markdown
# 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:`).
|