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

297 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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