feat(rapports) : repli/depliage a chaque niveau de la hierarchie (socle + 3 rapports) #288

Open
opened 2026-07-14 00:41:13 +00:00 by maximus · 0 comments
Owner

Refs: spec-decisions-collapse-multi-niveaux.md + spec-plan-collapse-multi-niveaux.md (plan v2, revu par /review-spec)

Contexte

Le repli des categories (#254, #265) ne permet de replier que le premier niveau. La taxonomie standard a trois niveaux : 11 racines, 41 categories intermediaires, 98 feuilles. Ces 41 intermediaires portent deja sous-total, style et indentation — mais aucun chevron.

Comportement cible

  • Tout est replie a l'ouverture ; seules les racines sont visibles.
  • Deplier un parent revele ses enfants directs, eux-memes replies (un descendant n'est visible que si TOUS ses ancetres sont deplies).
  • Chaque parent porte un chevron, a n'importe quelle profondeur.
  • « Tout deplier » ouvre tous les niveaux ; « Tout replier » ne laisse que les racines.

Algorithme : visibilite par remontee d'ancetres (PAS par adjacence)

La 1re version du plan suivait un curseur de profondeur sur des lignes supposees en ordre DFS. /review-spec a demontre que c'est faux (la grille Budget emet un ordre par niveau). L'algo retenu remonte parent_id : une ligne est visible ssi TOUS ses ancetres sont deplies. Independant de l'ordre des lignes.

// visibleRows : n'indexe QUE les parents (une feuille « (direct) » porte la cle 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) => {
  let key = acc.parentKeyOf(row), hops = 0;
  while (key !== null && hops++ < MAX_TREE_DEPTH) {   // garde anti-cycle
    const p = parents.get(key);
    if (p === undefined) return false;                 // ancetre hors section -> visible
    if (isCollapsed(p)) return true;
    key = acc.parentKeyOf(p);
  }
  return false;
};
return rows.filter((r) => !hiddenByAncestor(r));

Ce choix resout d'un coup : (1) l'ordre par niveau de la grille Budget, (2) le tri par type qui peut placer un enfant dans une autre section que son parent, (3) la feuille « (direct) » (masquee par construction), (4) la contrainte visible()-avant-reorderRows (disparait).

Travail a faire

  • src/utils/collapsibleRows.ts — reecrire visibleRows en remontee d'ancetres ; ajouter parentKeyOf a CollapseAccessors ; collapsibleKeys retourne tous les parents ; keyOf prefixe p:<id> (le rend injectif — aujourd'hui un sous-total parent et sa feuille « (direct) » partagent String(categoryId)).
  • src/utils/collapsibleRows.test.tsreecrire (les tests actuels verrouillent le mono-niveau : l.53-63, l.70-73, l.82-84).
  • src/hooks/useCollapsibleGroups.tspersister dans user_preferences (via getPreference/setPreference, async) au lieu de localStorage ; storageKey: string | null (null = pas de persistance) ; options.defaultExpanded (defaut false) ; semantique « Set = ecarts au defaut » ; collapseAll(rows). Ne PAS appeler useProfile() (throw hors provider ; la cle user_preferences est deja scopee par profil).
  • Les 3 tables — corriger les DEUX gates (pas seulement le bouton) :
    • const collapsed = isTopParent && groups.isCollapsed(row) -> isParent && … (ComparePeriodTable.tsx:157, BudgetVsActualTable.tsx:119, CategoryOverTimeTable.tsx:96). Sans ca, tout parent intermediaire aurait un chevron perpetuellement ouvert + aria-expanded={true} mensonger.
    • le gate du bouton isTopParent ? <button> : <span> -> isParent ? … (ComparePeriodTable.tsx:184, BudgetVsActualTable.tsx:140, CategoryOverTimeTable.tsx: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.
  • src/components/reports/overTimeTableModel.test.tsETENDRE la fixture a 3 niveaux (racine > parent intermediaire > feuilles) + assertions de cascade. (La fixture actuelle l.57-68 n'a que 2 niveaux : ses tests passent identiquement sous le nouvel algo — « mettre a jour » n'avait pas de cible.)
  • Supprimer src/components/reports/CategoryTable.tsx (74 lignes, 0 import).
  • CHANGELOG.md + CHANGELOG.fr.md.

Fichiers concernes

  • src/utils/collapsibleRows.ts (+ .test.ts)
  • src/hooks/useCollapsibleGroups.ts
  • src/services/userPreferenceService.ts (lu — getPreference/setPreference inchanges)
  • src/components/reports/{ComparePeriodTable,BudgetVsActualTable,CategoryOverTimeTable}.tsx
  • src/components/reports/overTimeTableModel.ts (+ .test.ts)
  • src/components/reports/CategoryTable.tsx (supprime)
  • src/i18n/locales/{fr,en}.json, CHANGELOG.md, CHANGELOG.fr.md

Tests (repo SANS jsdom/testing-library — logique en modules purs)

collapsibleRows.test.ts, reecrit :

  • Set vide ⇒ seules les racines visibles
  • deplier une racine revele uniquement ses enfants directs, pas les petits-enfants
  • deplier racine + intermediaire revele les feuilles
  • fixture en ordre par niveau (BFS) : masquage identique a une fixture DFSle test qui aurait tue la v1
  • sous-totaux en bas (reorderRows) : masquage inchange
  • feuille « (direct) » : parent replie ⇒ masquee ; jamais de chevron
  • parent_id cyclique : garde MAX_TREE_DEPTH, pas de boucle
  • ancetre absent de la section (enfant d'un autre type) : ligne visible, pas d'orphelin
  • collapsibleKeys liste tous les parents

Extraire la polarite en helper pur isCollapsedFor(flipped, key, defaultExpanded) (le hook n'est pas testable).

Review caveats

  • 🔴 Ordre des lignes (les 3 experts) : ne JAMAIS supposer un ordre DFS. L'algo remontee-ancetres est order-independant — c'est le point. Test BFS obligatoire.
  • 🟡 depth optionnel avec fallbacks derives (row.depth ?? (…?1:0)) : neutralise (depth ne pilote plus la visibilite, seulement indentation + aria-level). Une valeur fausse degrade l'affichage, ne masque plus.
  • 🟡 keyOf non injectif : corrige par le prefixe p: — le garde isParent redevient cosmetique.

Decisions prises en planification

  • Deplier revele le palier suivant, lui-meme replie (pas tout le sous-arbre) — tranche avec Max.
  • Persistance dans user_preferences (base du profil) et non localStorage : zero residu apres suppression de profil (deleteProfile ne purge aucun localStorage) — exigence privacy-first.
  • Bouton bulk agit sur tous les niveaux d'un coup.

Criteres d'acceptation

  • Toute categorie parente repliable a n'importe quel niveau (les 41 intermediaires incluses)
  • A l'ouverture, seules les racines visibles
  • Deplier un parent revele ses enfants directs, eux-memes replies
  • Un descendant visible ssi tous ses ancetres deplies
  • Masquage identique en DFS et en ordre par niveau
  • Un parent intermediaire replie affiche un chevron ferme + aria-expanded={false}
  • « Tout deplier » ouvre tous les niveaux d'un coup
  • Etat persiste dans user_preferences (isole par profil, detruit avec lui)
  • Sous-totaux et lignes de resultat inchanges, replie ou deplie
  • Lignes parentes portent aria-level

Spec source

spec-plan-collapse-multi-niveaux.md (v2)

Refs: `spec-decisions-collapse-multi-niveaux.md` + `spec-plan-collapse-multi-niveaux.md` (plan **v2**, revu par /review-spec) ## Contexte Le repli des categories (#254, #265) ne permet de replier que le **premier niveau**. La taxonomie standard a **trois niveaux** : 11 racines, **41 categories intermediaires**, 98 feuilles. Ces 41 intermediaires portent deja sous-total, style et indentation — mais **aucun chevron**. ## Comportement cible - Tout est **replie** a l'ouverture ; seules les racines sont visibles. - Deplier un parent revele **ses enfants directs, eux-memes replies** (un descendant n'est visible que si TOUS ses ancetres sont deplies). - **Chaque parent porte un chevron**, a n'importe quelle profondeur. - « Tout deplier » ouvre tous les niveaux ; « Tout replier » ne laisse que les racines. ## Algorithme : visibilite par remontee d'ancetres (PAS par adjacence) > La 1re version du plan suivait un curseur de profondeur sur des lignes supposees en ordre DFS. **/review-spec a demontre que c'est faux** (la grille Budget emet un ordre par niveau). L'algo retenu remonte `parent_id` : une ligne est visible ssi TOUS ses ancetres sont deplies. Independant de l'ordre des lignes. ```ts // visibleRows : n'indexe QUE les parents (une feuille « (direct) » porte la cle 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) => { let key = acc.parentKeyOf(row), hops = 0; while (key !== null && hops++ < MAX_TREE_DEPTH) { // garde anti-cycle const p = parents.get(key); if (p === undefined) return false; // ancetre hors section -> visible if (isCollapsed(p)) return true; key = acc.parentKeyOf(p); } return false; }; return rows.filter((r) => !hiddenByAncestor(r)); ``` Ce choix resout d'un coup : (1) l'ordre par niveau de la grille Budget, (2) le tri par type qui peut placer un enfant dans une autre section que son parent, (3) la feuille « (direct) » (masquee par construction), (4) la contrainte visible()-avant-reorderRows (disparait). ## Travail a faire - [ ] `src/utils/collapsibleRows.ts` — reecrire `visibleRows` en remontee d'ancetres ; ajouter `parentKeyOf` a `CollapseAccessors` ; `collapsibleKeys` retourne **tous** les parents ; `keyOf` prefixe `p:<id>` (le rend **injectif** — aujourd'hui un sous-total parent et sa feuille « (direct) » partagent `String(categoryId)`). - [ ] `src/utils/collapsibleRows.test.ts` — **reecrire** (les tests actuels verrouillent le mono-niveau : l.53-63, l.70-73, l.82-84). - [ ] `src/hooks/useCollapsibleGroups.ts` — **persister dans `user_preferences`** (via `getPreference`/`setPreference`, async) au lieu de `localStorage` ; `storageKey: string | null` (null = pas de persistance) ; `options.defaultExpanded` (defaut false) ; semantique « Set = ecarts au defaut » ; `collapseAll(rows)`. **Ne PAS** appeler `useProfile()` (throw hors provider ; la cle user_preferences est deja scopee par profil). - [ ] Les 3 tables — corriger **les DEUX gates** (pas seulement le bouton) : - `const collapsed = isTopParent && groups.isCollapsed(row)` -> `isParent && …` (ComparePeriodTable.tsx:157, BudgetVsActualTable.tsx:119, CategoryOverTimeTable.tsx:96). Sans ca, tout parent intermediaire aurait un chevron **perpetuellement ouvert** + `aria-expanded={true}` mensonger. - le gate du bouton `isTopParent ? <button> : <span>` -> `isParent ? …` (ComparePeriodTable.tsx:184, BudgetVsActualTable.tsx:140, CategoryOverTimeTable.tsx: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. - [ ] `src/components/reports/overTimeTableModel.test.ts` — **ETENDRE la fixture a 3 niveaux** (racine > parent intermediaire > feuilles) + assertions de cascade. (La fixture actuelle l.57-68 n'a que 2 niveaux : ses tests passent identiquement sous le nouvel algo — « mettre a jour » n'avait pas de cible.) - [ ] Supprimer `src/components/reports/CategoryTable.tsx` (74 lignes, **0 import**). - [ ] CHANGELOG.md + CHANGELOG.fr.md. ## Fichiers concernes - `src/utils/collapsibleRows.ts` (+ `.test.ts`) - `src/hooks/useCollapsibleGroups.ts` - `src/services/userPreferenceService.ts` (lu — `getPreference`/`setPreference` inchanges) - `src/components/reports/{ComparePeriodTable,BudgetVsActualTable,CategoryOverTimeTable}.tsx` - `src/components/reports/overTimeTableModel.ts` (+ `.test.ts`) - `src/components/reports/CategoryTable.tsx` (**supprime**) - `src/i18n/locales/{fr,en}.json`, `CHANGELOG.md`, `CHANGELOG.fr.md` ## Tests (repo SANS jsdom/testing-library — logique en modules purs) `collapsibleRows.test.ts`, reecrit : - Set vide ⇒ seules les racines visibles - deplier une racine revele **uniquement** ses enfants directs, pas les petits-enfants - deplier racine + intermediaire revele les feuilles - **fixture en ordre par niveau (BFS) : masquage identique a une fixture DFS** — *le test qui aurait tue la v1* - sous-totaux en bas (reorderRows) : masquage inchange - feuille « (direct) » : parent replie ⇒ masquee ; jamais de chevron - `parent_id` cyclique : garde MAX_TREE_DEPTH, pas de boucle - ancetre absent de la section (enfant d'un autre type) : ligne visible, pas d'orphelin - `collapsibleKeys` liste tous les parents Extraire la polarite en helper pur `isCollapsedFor(flipped, key, defaultExpanded)` (le hook n'est pas testable). ## Review caveats - 🔴 **Ordre des lignes** (les 3 experts) : ne JAMAIS supposer un ordre DFS. L'algo remontee-ancetres est order-independant — c'est le point. Test BFS obligatoire. - 🟡 **`depth` optionnel** avec fallbacks derives (`row.depth ?? (…?1:0)`) : neutralise (depth ne pilote plus la visibilite, seulement indentation + aria-level). Une valeur fausse degrade l'affichage, ne masque plus. - 🟡 **`keyOf` non injectif** : corrige par le prefixe `p:` — le garde `isParent` redevient cosmetique. ## Decisions prises en planification - Deplier revele le palier suivant, lui-meme replie (pas tout le sous-arbre) — tranche avec Max. - Persistance dans `user_preferences` (base du profil) et non `localStorage` : zero residu apres suppression de profil (deleteProfile ne purge aucun localStorage) — exigence privacy-first. - Bouton bulk agit sur tous les niveaux d'un coup. ## Criteres d'acceptation - [ ] Toute categorie parente repliable a n'importe quel niveau (les 41 intermediaires incluses) - [ ] A l'ouverture, seules les racines visibles - [ ] Deplier un parent revele ses enfants directs, eux-memes replies - [ ] Un descendant visible ssi tous ses ancetres deplies - [ ] **Masquage identique en DFS et en ordre par niveau** - [ ] Un parent intermediaire replie affiche un chevron **ferme** + `aria-expanded={false}` - [ ] « Tout deplier » ouvre tous les niveaux d'un coup - [ ] Etat persiste dans `user_preferences` (isole par profil, detruit avec lui) - [ ] Sous-totaux et lignes de resultat inchanges, replie ou deplie - [ ] Lignes parentes portent `aria-level` ## Spec source spec-plan-collapse-multi-niveaux.md (v2)
maximus added this to the planned-2026-07-15-collapse-multi-niveaux milestone 2026-07-14 00:41:13 +00:00
maximus added the
status:ready
type:feature
source:human
labels 2026-07-14 00:41:13 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#288
No description provided.