fix: check category parents and repair orphan categories (#393) #472

Open
maximus wants to merge 7 commits from issue-393-category-hierarchy into issue-429-category-combobox-portal
Owner

Resolves #393
Generated autonomously by /autopilot run of 2026-10-18

Résumé

Maillon 5 sur 8 de la pile planned-2026-10-16-lot-v018, basé sur issue-429-category-combobox-portal (PR #471), dont il utilise le portail de CategoryCombobox : la liste d'un combobox de la fenêtre sort de la fenêtre.

Un parent_id en boucle (A sous B, B sous A, ou une catégorie sous elle-même) cachait la boucle et tout ce qui pend dessous partout où l'on part des racines : arbre, sélecteurs, budget, rapports. Rien ne permettait de le voir ni de le réparer, et updateCategory écrivait n'importe quel parent_id. Désormais :

  • Module pur unique src/utils/categoryHierarchy.ts : depthsOf, subtreeHeight et descendantsOf (une seule descente), sortis de parentOptions.ts, avec un ensemble de visités sur chaque remontée et chaque descente ; plus fitsUnder (trois niveaux, sous-arbre compris), parentChangeRefusal, findOrphans, displaysAsRoot. buildParentOptions, le diagnostic et la validation du service l'importent ; aucune autre implémentation. parentOptions.test.ts reste vert sans changement de ses tests existants. Une catégorie prise dans une boucle, ou dessous, n'a plus de profondeur (au lieu d'une profondeur inventée) : la liste des parents ne la propose jamais.
  • Diagnostic findOrphanCategories : requête dédiée SELECT id, parent_id, is_active FROM categories (actives et inactives), motifs cycle (le membre de plus petit id, une entrée par boucle), inactive_parent, missing_parent ; seule la tête de chaque groupe est rendue.
  • Validation (checkNewParent, partagée) : self_parent, cycle (parent dans la descendance, ou parent sans profondeur, pris dans une autre boucle), too_deep (sur les catégories actives, sous-arbre compris), parent_has_transactions (transactions directes du nouveau parent). Sinon, le nouveau parent passe à is_inputable = 0 comme dans createCategory. Lectures et écritures sous un withTransaction, par son handle, même pour une ligne. updateCategory ne vérifie qu'un parent changé ; reattachCategory(id, parentId | null) vérifie toujours. La racine est toujours acceptée.
  • CategoryHierarchyError (code), sur le modèle de TransactionLinkedToBalanceError ; useCategories la reconnaît par instanceof et dispatche la clé categories.hierarchy.errors.<code> (état CategoriesError = { key } | { message }, toutes les erreurs du hook passent par categoriesErrorOf).
  • getCategoryDepth garde les id vus et rend Infinity sur une boucle (se termine ; createCategory refuse alors ce parent).
  • UI : bouton « Vérifier la hiérarchie » dans l'en-tête de la page Catégories, entre « Tous les mots-clés » et « Réinitialiser », même style que « Réinitialiser » (désactivé pendant un enregistrement, comme lui). Il ouvre HierarchyCheckModal : nombre d'orphelines ou « aucune », une ligne par orpheline (nom traduit, motif traduit, CategoryCombobox alimenté par buildParentOptions avec l'orpheline en catégorie éditée, « Racine (niveau supérieur) » présélectionnée en extraOptions, bouton « Rattacher »), bouton « Fermer ». Après un rattachement : une ligne logInfo (motif et nombre de catégories remises en place, aucun nom), puis la liste et l'arbre se rechargent (l'arbre même si la nouvelle vérification échoue, l'écriture étant faite). Un refus s'affiche traduit dans la fenêtre ; à l'enregistrement du formulaire, dans le bandeau d'erreur existant de la page, juste au-dessus du formulaire, qui reste ouvert.
  • Budget : buildBudgetYearRows (useBudget.ts) et getBudgetVsActualData (budgetService.ts) placent au premier niveau une catégorie active dont le parent est désactivé ou absent (displaysAsRoot), comme l'arbre et les sélecteurs.

Fichiers

Fichier Changement
src/utils/categoryHierarchy.ts nouveau module pur (règles de la hiérarchie)
src/utils/categoryHierarchy.test.ts 27 tests
src/components/categories/parentOptions.ts importe le module ; une catégorie sans profondeur n'est jamais proposée
src/components/categories/parentOptions.test.ts +1 test (membres d'une boucle jamais proposés)
src/services/categoryService.ts CategoryHierarchyError, findOrphanCategories, checkNewParent, updateCategory sous transaction, reattachCategory, garde de getCategoryDepth, markAsParent partagé, commentaire de updateCategorySortOrders
src/services/categoryService.test.ts nouveau, 19 tests (base factice en mémoire, handle de transaction distinct de getDb())
src/hooks/useCategories.ts CategoriesError, categoriesErrorOf, categoriesErrorText, état hierarchyCheck, checkHierarchy, reattachOrphan, closeHierarchyCheck, buildTree exporté, reattachLogLine
src/hooks/useCategories.test.ts nouveau, 11 tests (harnais hookHarness)
src/components/categories/HierarchyCheckModal.tsx nouveau composant (fenêtre)
src/components/categories/HierarchyCheckModal.render.test.tsx 9 tests (renderToStaticMarkup)
src/components/categories/HierarchyCheckModal.wiring.test.ts 5 tests (harnais)
src/pages/CategoriesPage.tsx bouton, fenêtre, erreur traduite
src/pages/CategoriesPage.render.test.tsx +5 tests
src/hooks/useBudget.ts, src/services/budgetService.ts règle de racine
src/hooks/useBudget.test.ts, src/services/budgetService.test.ts +3 et +2 tests
src/components/shared/CategoryCombobox.test.ts +1 test (comportement sur boucle documenté)
src/i18n/locales/fr.json, en.json categories.hierarchy.* ; docs.categories : 1 fonctionnalité, 1 étape, 2 astuces
docs/guide-utilisateur.md §6 Catégories
docs/architecture.md utilitaires 19 → 20, lignes categoryService, useCategories, /categories, section « Hiérarchie des catégories (#393) » (dont le hors portée)
CHANGELOG.md, CHANGELOG.fr.md Added / Ajouté, Fixed / Corrigé

Aucune migration (v1→v17 inchangées), aucun fichier Rust, aucun appel réseau, aucune écriture d'une table lue par COUNTERS_SQL (seule categories est écrite).

Hors portée

  • Glisser-déposer (décision de Max) : updateCategorySortOrders écrit parent_id sans cette validation ; le chemin reste gardé par l'interface (moveCategory ne peut pas déposer une catégorie dans son propre sous-arbre : la cible n'est pas trouvée, rien n'est écrit). Il ne refuse pas non plus un parent avec transactions et ne touche pas is_inputable. Écrit dans docs/architecture.md et en commentaire de la fonction.
  • Restauration d'une sauvegarde (restoreCategories) : réinsère les parent_id tels quels, boucles comprises ; la fenêtre répare après coup.
  • Le sélecteur de parent du formulaire (fait par #423, parentOptions.ts) : seulement branché sur le module.

Décisions

Toutes consignées dans reports/run-2026-10-18-lot-v018/decisions-log.md : 5 HIGH, 11 MEDIUM, 0 LOW.

  • HIGH — Préflight passé (#369, #419, #421 fermées, tag v0.17.2) ; sites repérés par leur contenu.
  • HIGH — Module unique comme le dit le plan ; computeDepths de CategoryCombobox (indentation seulement) laissé à part : ce n'est pas l'un des trois consommateurs nommés, et le maillon 4 vient de toucher le fichier.
  • MEDIUM — depthsOf ne donne aucune profondeur à une catégorie dans ou sous une boucle, au lieu de la profondeur inventée de l'ancien seen → 0 (dans une boucle 1 ↔ 2, 2 recevait la profondeur 1 et était proposé comme parent à la création, masqué seulement par le DFS du combobox). Un parent sans profondeur est refusé en cycle. Écarté : un cinquième code (parent_in_cycle) ; le plan fixe quatre codes et le message cycle couvre les deux cas.
  • MEDIUM — La racine est toujours acceptée, même pour une branche de plus de trois niveaux : c'est la réparation de dernier recours d'une boucle. Limite connue : rattacher à la racine le membre d'une boucle de 3 membres ou plus, avec des catégories dessous, peut laisser une branche de 4 niveaux (l'arbre l'affiche, la grille Budget s'arrête au troisième). Inatteignable depuis #423 ; avant, le formulaire ne pouvait créer que des boucles de 1 ou 2 membres.
  • MEDIUM — updateCategory ne vérifie qu'un parent changé : un renommage d'une catégorie enregistrée avant ces règles (4e niveau, parent avec transactions, que createCategory n'a jamais vérifié) ne doit pas être refusé.
  • MEDIUM — Un nouveau parent inactif ou inexistant lève une erreur simple (« Parent category not found or inactive »), pas une CategoryHierarchyError : hors d'atteinte de l'interface (les deux sélecteurs ne listent que des catégories actives), et le plan fixe les quatre codes.
  • MEDIUM — getCategoryDepth rend Infinity sur une boucle, pour que le contrôle existant de createCategory (>= 2) refuse un membre de boucle comme parent (chemin hors d'atteinte : les sélecteurs écartent ces catégories).
  • MEDIUM — État d'erreur CategoriesError = { key } | { message } et categoriesErrorText pour la page et la fenêtre. Écarté : la clé dans l'ancien champ texte (la page ne saurait pas distinguer une clé d'un message).
  • MEDIUM — Le refus du formulaire s'affiche dans le bandeau d'erreur existant de la page, juste au-dessus du formulaire, qui reste ouvert avec la saisie (là où s'affiche déjà toute erreur d'enregistrement) ; la fenêtre a son propre bandeau (role="alert"), le bandeau de la page étant sous son voile. Écarté : un second affichage dans CategoryDetailPanel.
  • MEDIUM — Journal : une ligne logInfo dès que le rattachement est écrit, avant les rechargements : Categories: orphan category reattached (<motif>), N categories back in the hierarchy (N = la catégorie et ses descendantes actives, rendu par reattachCategory). Ni nom ni identifiant.
  • MEDIUM — L'is_inputable de l'ancien parent n'est pas touché après un déplacement (hors du plan ; deactivateCategory le restaure, le glisser-déposer non) : la case « Autoriser la saisie » du formulaire le règle.
  • MEDIUM — Libellés : inactive_parent se lit « Son parent a été supprimé » (« Supprimer » désactive ; c'est le mot de l'utilisateur), missing_parent « Son parent est introuvable », l'option racine « Racine (niveau supérieur) », alignée sur « Aucun parent (niveau supérieur) » du formulaire.
  • MEDIUM — Relecture avant la PR (cb0e2d2) : après un rattachement écrit, un échec de la seconde vérification sautait le rechargement de l'arbre ; il passe dans un finally, l'échec s'affiche toujours dans la fenêtre (test ajouté, M34). Écarté : recharger l'arbre avant la vérification (les boutons « Rattacher » redeviendraient actifs sur la liste périmée).
  • HIGH — Fenêtre présentationnelle ; son état vit dans useCategories (hierarchyCheck, une vérification qui finit après la fermeture ne rouvre rien) ; un parent choisi qui n'est plus proposé retombe sur la racine ; pas de gestion d'Échap, comme la fenêtre « Réinitialiser » de la page (#451 suit ce motif).
  • HIGH — Budget par displaysAsRoot dans les deux constructeurs ; markAsParent partagé par les trois écritures ; toSqlFlag pour is_inputable ; buildTree exporté pour le test de régression.

Preuves

Test de régression (type:bug) : categoryService.test.ts reproduit A → B → A par updateCategory (A racine, B son enfant, A enregistrée sous B) : refus cycle, BEGIN puis ROLLBACK, rien d'écrit, l'arbre (buildTree) montre toujours A et B. Sur des lignes déjà en boucle (5 ↔ 3 avec 8 sous 5, puis 6 → 4 → 9 → 6), le diagnostic trouve le plus petit membre, reattachCategory se termine, casse la boucle (diagnostic vide ensuite) et buildTree montre de nouveau toute la branche. Réserve de la revue du plan : reattachCategory sur un membre d'une boucle de 2, puis de 3, se termine et casse la boucle (même fichier).

Mutations : implémentation commitée avant la première ; une mutation à la fois, restaurée par git checkout --, git status --short vide après chacune ; vitest lancé par node node_modules/vitest/vitest.mjs run --pool=threads <fichiers> sous timeout -s KILL 90 et NODE_OPTIONS=--max-old-space-size=768 (les mutations des gardes de visités rendent les parcours infinis).

# Fichier Mutation Tests rouges rc
M1 categoryHierarchy.ts règle self_parent retirée 2 1
M2 categoryHierarchy.ts règle cycle (parent dans la descendance) retirée 3 1
M3 categoryHierarchy.ts règle cycle (parent sans profondeur) retirée 1 1
M4 categoryHierarchy.ts règle too_deep retirée 3 1
M5 categoryHierarchy.ts fitsUnder sans le niveau de la catégorie 13 1
M6 categoryHierarchy.ts racine plus toujours acceptée 1 1
M7 categoryService.ts règle parent_has_transactions retirée 2 1
M8 categoryService.ts updateCategory : le nouveau parent garde is_inputable 1 1
M9 categoryService.ts reattachCategory : le nouveau parent garde is_inputable 1 1
M10 categoryService.ts parent inchangé vérifié aussi 1 1
M11 categoryService.ts lecture par getDb() dans la transaction 8 1
M12 categoryService.ts pas de BEGIN (hors transaction) 8 1
M13 categoryService.ts garde de visités de getCategoryDepth retirée worker à court de mémoire (« Worker exited unexpectedly », le parcours ne finit pas) 1
M14 categoryService.ts boucle : rend les pas comptés au lieu d'Infinity 1 1
M15 categoryHierarchy.ts garde de visités de depthsOf retirée abandon V8 par manque de mémoire (le parcours ne finit pas) -6
M16 categoryHierarchy.ts garde de visités de la descente retirée worker à court de mémoire 1
M17 categoryHierarchy.ts garde de visités de findOrphans retirée 4 (RangeError: Invalid array length après ~5 s chacun) 1
M18 categoryHierarchy.ts membre de boucle : le premier rencontré au lieu du plus petit id 4 1
M19 categoryHierarchy.ts parent inactif traversé comme actif 3 1
M20 parentOptions.ts un membre de boucle compte comme une racine 3 1
M21 useBudget.ts ancienne règle de racine (!c.parent_id) 2 1
M22 budgetService.ts ancienne règle de racine 2 1
M23 useCategories.ts refus affiché par e.message 3 1
M24 useCategories.ts pas de ligne de Journal 2 1
M25 useCategories.ts arbre non rechargé après un rattachement 2 1
M26 useCategories.ts une vérification tardive rouvre une fenêtre fermée 1 1
M27 HierarchyCheckModal.tsx racine non présélectionnée 3 1
M28 HierarchyCheckModal.tsx « Rattacher » ignore le parent choisi 1 1
M29 HierarchyCheckModal.tsx options sans l'orpheline comme catégorie éditée 1 1
M30 HierarchyCheckModal.tsx un parent qui n'est plus proposé est quand même transmis 1 1
M31 CategoriesPage.tsx clé d'un refus affichée sans traduction 1 1
M32 CategoriesPage.tsx bouton d'un autre style que « Réinitialiser » 1 1
M33 CategoriesPage.tsx bouton actif pendant un enregistrement 1 1
M34 useCategories.ts arbre non rechargé quand la vérification qui suit échoue 1 1
R1 categoryService.ts (relecture) parent inactif plus vérifié comme disponible 1 1
R3 categoryService.ts (relecture) le compte inclut les descendantes inactives 1 1
R4 HierarchyCheckModal.tsx (relecture) nom d'une catégorie standard non traduit 1 1
R5 categoryHierarchy.ts (relecture) orphelines non triées par id 1 1

38 mutations, toutes rouges. M23 à M26 rejouées après cb0e2d2 ; R1, R3, R4 et R5, de l'autorelecture, rejouées sur la tête 8a9d990 (R3 après l'ajout du test qui l'épingle). Aucun processus vitest resté actif, aucun fichier laissé dans l'arbre (le vidage mémoire de M15 est pris par apport, hors du dépôt).

Suites : sonde rc=1 (src/zz_tmp_probe.test.ts volontairement rouge, supprimé) ; npm run build vert ; npm test 152 fichiers / 2458 tests verts (147 / 2375 sur la base). Rust : non concerné (aucun fichier Rust).

Vérifications manuelles dues (prochaine release)

Sur un profil de test, application fermée pour les modifications SQLite (une boucle ne peut plus se créer par l'interface depuis #423) :

  1. En-tête de la page Catégories : « Vérifier la hiérarchie » entre « Tous les mots-clés » et « Réinitialiser », même style que « Réinitialiser », en thème clair et sombre ; grisé pendant un enregistrement.
  2. Profil sain : le bouton ouvre la fenêtre, qui dit « Aucune catégorie orpheline : chaque catégorie est à sa place dans la hiérarchie. » ; « Fermer » la ferme.
  3. Boucle : dans la base du profil, UPDATE categories SET parent_id = <id d'un enfant> WHERE id = <id de son parent> ; relancer. La branche a disparu de l'arbre, du sélecteur de catégorie des Transactions, de la grille Budget et des rapports. La fenêtre compte 1 orpheline, nommée (le membre de plus petit id), motif « Prise dans une boucle de parenté », « Racine (niveau supérieur) » présélectionnée. « Rattacher » : la branche revient dans l'arbre, les sélecteurs, la grille Budget et les rapports, avec ses transactions, ses mots-clés et ses montants de budget ; la fenêtre dit « aucune ».
  4. Journal (Paramètres → Systèmes → Journaux) après le point 3 : une seule ligne Categories: orphan category reattached (cycle), N categories back in the hierarchy, sans nom de catégorie.
  5. Dans la fenêtre, choisir un autre parent : la liste du combobox s'ouvre par-dessus la fenêtre, sans être coupée par la liste défilante des lignes (portail de #429), avec les parents du formulaire. « Rattacher » sous un parent qui a des transactions à son nom : message traduit en haut de la fenêtre (« Ce parent a des transactions à son nom… »), rien de changé.
  6. Formulaire de catégorie : choisir comme parent une catégorie qui a des transactions à son nom, « Enregistrer » : message traduit dans le bandeau rouge au-dessus des panneaux, formulaire toujours ouvert, rien d'enregistré. Renommer cette même catégorie sans changer son parent : enregistré.
  7. Parent désactivé : UPDATE categories SET is_active = 0 WHERE id = <id d'un parent> (sans remonter ses enfants) ; relancer. L'enfant s'affiche au premier niveau dans la grille Budget et le rapport Budget vs réel, avec ses montants ; la fenêtre le liste « Son parent a été supprimé » ; « Rattacher » sous un parent valide le replace.
  8. Fenêtre, bandeau d'erreur, motifs et boutons lisibles en thème clair et sombre ; textes en français et en anglais (nombre au singulier et au pluriel).

À regarder

  • La nouvelle sémantique de depthsOf (aucune profondeur dans ou sous une boucle) change ce que buildParentOptions propose dans ce cas ; épinglé par un test ajouté à parentOptions.test.ts, les tests existants inchangés.
  • updateCategory tient maintenant le verrou de db.ts pour toute sa lecture-vérification-écriture (withTransaction) au lieu d'un seul execute.
  • La limite connue de la racine toujours acceptée (branche de 4 niveaux possible après la réparation d'une boucle de 3 membres ou plus avec des catégories dessous).
  • getCategoryDepth compte encore un parent inactif comme un niveau (comportement d'avant, chemin de createCategory seulement), alors que la validation et l'arbre le traitent comme absent : plus strict d'un niveau, laissé tel quel.

🤖 Generated with Claude Code

Resolves #393 Generated autonomously by /autopilot run of 2026-10-18 ## Résumé Maillon 5 sur 8 de la pile `planned-2026-10-16-lot-v018`, basé sur `issue-429-category-combobox-portal` (PR #471), dont il utilise le portail de `CategoryCombobox` : la liste d'un combobox de la fenêtre sort de la fenêtre. Un `parent_id` en boucle (A sous B, B sous A, ou une catégorie sous elle-même) cachait la boucle et tout ce qui pend dessous partout où l'on part des racines : arbre, sélecteurs, budget, rapports. Rien ne permettait de le voir ni de le réparer, et `updateCategory` écrivait n'importe quel `parent_id`. Désormais : - **Module pur unique** `src/utils/categoryHierarchy.ts` : `depthsOf`, `subtreeHeight` et `descendantsOf` (une seule descente), sortis de `parentOptions.ts`, avec un ensemble de visités sur chaque remontée et chaque descente ; plus `fitsUnder` (trois niveaux, sous-arbre compris), `parentChangeRefusal`, `findOrphans`, `displaysAsRoot`. `buildParentOptions`, le diagnostic et la validation du service l'importent ; aucune autre implémentation. `parentOptions.test.ts` reste vert sans changement de ses tests existants. Une catégorie prise dans une boucle, ou dessous, n'a plus de profondeur (au lieu d'une profondeur inventée) : la liste des parents ne la propose jamais. - **Diagnostic** `findOrphanCategories` : requête dédiée `SELECT id, parent_id, is_active FROM categories` (actives et inactives), motifs `cycle` (le membre de plus petit `id`, une entrée par boucle), `inactive_parent`, `missing_parent` ; seule la tête de chaque groupe est rendue. - **Validation** (`checkNewParent`, partagée) : `self_parent`, `cycle` (parent dans la descendance, ou parent sans profondeur, pris dans une autre boucle), `too_deep` (sur les catégories actives, sous-arbre compris), `parent_has_transactions` (transactions directes du nouveau parent). Sinon, le nouveau parent passe à `is_inputable = 0` comme dans `createCategory`. Lectures et écritures sous **un** `withTransaction`, par son handle, même pour une ligne. `updateCategory` ne vérifie qu'un parent **changé** ; `reattachCategory(id, parentId | null)` vérifie toujours. La racine est toujours acceptée. - **`CategoryHierarchyError`** (`code`), sur le modèle de `TransactionLinkedToBalanceError` ; `useCategories` la reconnaît par `instanceof` et dispatche la clé `categories.hierarchy.errors.<code>` (état `CategoriesError = { key } | { message }`, toutes les erreurs du hook passent par `categoriesErrorOf`). - **`getCategoryDepth`** garde les `id` vus et rend `Infinity` sur une boucle (se termine ; `createCategory` refuse alors ce parent). - **UI** : bouton « Vérifier la hiérarchie » dans l'en-tête de la page Catégories, entre « Tous les mots-clés » et « Réinitialiser », même style que « Réinitialiser » (désactivé pendant un enregistrement, comme lui). Il ouvre `HierarchyCheckModal` : nombre d'orphelines ou « aucune », une ligne par orpheline (nom traduit, motif traduit, `CategoryCombobox` alimenté par `buildParentOptions` avec l'orpheline en catégorie éditée, « Racine (niveau supérieur) » présélectionnée en `extraOptions`, bouton « Rattacher »), bouton « Fermer ». Après un rattachement : une ligne `logInfo` (motif et nombre de catégories remises en place, aucun nom), puis la liste et l'arbre se rechargent (l'arbre même si la nouvelle vérification échoue, l'écriture étant faite). Un refus s'affiche traduit dans la fenêtre ; à l'enregistrement du formulaire, dans le bandeau d'erreur existant de la page, juste au-dessus du formulaire, qui reste ouvert. - **Budget** : `buildBudgetYearRows` (`useBudget.ts`) et `getBudgetVsActualData` (`budgetService.ts`) placent au premier niveau une catégorie active dont le parent est désactivé ou absent (`displaysAsRoot`), comme l'arbre et les sélecteurs. ## Fichiers | Fichier | Changement | |---|---| | `src/utils/categoryHierarchy.ts` | nouveau module pur (règles de la hiérarchie) | | `src/utils/categoryHierarchy.test.ts` | 27 tests | | `src/components/categories/parentOptions.ts` | importe le module ; une catégorie sans profondeur n'est jamais proposée | | `src/components/categories/parentOptions.test.ts` | +1 test (membres d'une boucle jamais proposés) | | `src/services/categoryService.ts` | `CategoryHierarchyError`, `findOrphanCategories`, `checkNewParent`, `updateCategory` sous transaction, `reattachCategory`, garde de `getCategoryDepth`, `markAsParent` partagé, commentaire de `updateCategorySortOrders` | | `src/services/categoryService.test.ts` | nouveau, 19 tests (base factice en mémoire, handle de transaction distinct de `getDb()`) | | `src/hooks/useCategories.ts` | `CategoriesError`, `categoriesErrorOf`, `categoriesErrorText`, état `hierarchyCheck`, `checkHierarchy`, `reattachOrphan`, `closeHierarchyCheck`, `buildTree` exporté, `reattachLogLine` | | `src/hooks/useCategories.test.ts` | nouveau, 11 tests (harnais `hookHarness`) | | `src/components/categories/HierarchyCheckModal.tsx` | nouveau composant (fenêtre) | | `src/components/categories/HierarchyCheckModal.render.test.tsx` | 9 tests (`renderToStaticMarkup`) | | `src/components/categories/HierarchyCheckModal.wiring.test.ts` | 5 tests (harnais) | | `src/pages/CategoriesPage.tsx` | bouton, fenêtre, erreur traduite | | `src/pages/CategoriesPage.render.test.tsx` | +5 tests | | `src/hooks/useBudget.ts`, `src/services/budgetService.ts` | règle de racine | | `src/hooks/useBudget.test.ts`, `src/services/budgetService.test.ts` | +3 et +2 tests | | `src/components/shared/CategoryCombobox.test.ts` | +1 test (comportement sur boucle documenté) | | `src/i18n/locales/fr.json`, `en.json` | `categories.hierarchy.*` ; `docs.categories` : 1 fonctionnalité, 1 étape, 2 astuces | | `docs/guide-utilisateur.md` | §6 Catégories | | `docs/architecture.md` | utilitaires 19 → 20, lignes `categoryService`, `useCategories`, `/categories`, section « Hiérarchie des catégories (#393) » (dont le hors portée) | | `CHANGELOG.md`, `CHANGELOG.fr.md` | Added / Ajouté, Fixed / Corrigé | Aucune migration (v1→v17 inchangées), aucun fichier Rust, aucun appel réseau, aucune écriture d'une table lue par `COUNTERS_SQL` (seule `categories` est écrite). ## Hors portée - **Glisser-déposer** (décision de Max) : `updateCategorySortOrders` écrit `parent_id` sans cette validation ; le chemin reste gardé par l'interface (`moveCategory` ne peut pas déposer une catégorie dans son propre sous-arbre : la cible n'est pas trouvée, rien n'est écrit). Il ne refuse pas non plus un parent avec transactions et ne touche pas `is_inputable`. Écrit dans `docs/architecture.md` et en commentaire de la fonction. - **Restauration d'une sauvegarde** (`restoreCategories`) : réinsère les `parent_id` tels quels, boucles comprises ; la fenêtre répare après coup. - Le sélecteur de parent du formulaire (fait par #423, `parentOptions.ts`) : seulement branché sur le module. ## Décisions Toutes consignées dans `reports/run-2026-10-18-lot-v018/decisions-log.md` : 5 HIGH, 11 MEDIUM, 0 LOW. - **HIGH** — Préflight passé (#369, #419, #421 fermées, tag `v0.17.2`) ; sites repérés par leur contenu. - **HIGH** — Module unique comme le dit le plan ; `computeDepths` de `CategoryCombobox` (indentation seulement) laissé à part : ce n'est pas l'un des trois consommateurs nommés, et le maillon 4 vient de toucher le fichier. - **MEDIUM** — `depthsOf` ne donne **aucune** profondeur à une catégorie dans ou sous une boucle, au lieu de la profondeur inventée de l'ancien `seen → 0` (dans une boucle 1 ↔ 2, 2 recevait la profondeur 1 et était proposé comme parent à la création, masqué seulement par le DFS du combobox). Un parent sans profondeur est refusé en `cycle`. Écarté : un cinquième code (`parent_in_cycle`) ; le plan fixe quatre codes et le message `cycle` couvre les deux cas. - **MEDIUM** — La racine est toujours acceptée, même pour une branche de plus de trois niveaux : c'est la réparation de dernier recours d'une boucle. Limite connue : rattacher à la racine le membre d'une boucle de 3 membres ou plus, avec des catégories dessous, peut laisser une branche de 4 niveaux (l'arbre l'affiche, la grille Budget s'arrête au troisième). Inatteignable depuis #423 ; avant, le formulaire ne pouvait créer que des boucles de 1 ou 2 membres. - **MEDIUM** — `updateCategory` ne vérifie qu'un parent changé : un renommage d'une catégorie enregistrée avant ces règles (4e niveau, parent avec transactions, que `createCategory` n'a jamais vérifié) ne doit pas être refusé. - **MEDIUM** — Un nouveau parent inactif ou inexistant lève une erreur simple (« Parent category not found or inactive »), pas une `CategoryHierarchyError` : hors d'atteinte de l'interface (les deux sélecteurs ne listent que des catégories actives), et le plan fixe les quatre codes. - **MEDIUM** — `getCategoryDepth` rend `Infinity` sur une boucle, pour que le contrôle existant de `createCategory` (`>= 2`) refuse un membre de boucle comme parent (chemin hors d'atteinte : les sélecteurs écartent ces catégories). - **MEDIUM** — État d'erreur `CategoriesError = { key } | { message }` et `categoriesErrorText` pour la page et la fenêtre. Écarté : la clé dans l'ancien champ texte (la page ne saurait pas distinguer une clé d'un message). - **MEDIUM** — Le refus du formulaire s'affiche dans le bandeau d'erreur existant de la page, juste au-dessus du formulaire, qui reste ouvert avec la saisie (là où s'affiche déjà toute erreur d'enregistrement) ; la fenêtre a son propre bandeau (`role="alert"`), le bandeau de la page étant sous son voile. Écarté : un second affichage dans `CategoryDetailPanel`. - **MEDIUM** — Journal : une ligne `logInfo` dès que le rattachement est écrit, avant les rechargements : `Categories: orphan category reattached (<motif>), N categories back in the hierarchy` (N = la catégorie et ses descendantes actives, rendu par `reattachCategory`). Ni nom ni identifiant. - **MEDIUM** — L'`is_inputable` de l'ancien parent n'est pas touché après un déplacement (hors du plan ; `deactivateCategory` le restaure, le glisser-déposer non) : la case « Autoriser la saisie » du formulaire le règle. - **MEDIUM** — Libellés : `inactive_parent` se lit « Son parent a été supprimé » (« Supprimer » désactive ; c'est le mot de l'utilisateur), `missing_parent` « Son parent est introuvable », l'option racine « Racine (niveau supérieur) », alignée sur « Aucun parent (niveau supérieur) » du formulaire. - **MEDIUM** — Relecture avant la PR (`cb0e2d2`) : après un rattachement écrit, un échec de la seconde vérification sautait le rechargement de l'arbre ; il passe dans un `finally`, l'échec s'affiche toujours dans la fenêtre (test ajouté, M34). Écarté : recharger l'arbre avant la vérification (les boutons « Rattacher » redeviendraient actifs sur la liste périmée). - **HIGH** — Fenêtre présentationnelle ; son état vit dans `useCategories` (`hierarchyCheck`, une vérification qui finit après la fermeture ne rouvre rien) ; un parent choisi qui n'est plus proposé retombe sur la racine ; pas de gestion d'Échap, comme la fenêtre « Réinitialiser » de la page (#451 suit ce motif). - **HIGH** — Budget par `displaysAsRoot` dans les deux constructeurs ; `markAsParent` partagé par les trois écritures ; `toSqlFlag` pour `is_inputable` ; `buildTree` exporté pour le test de régression. ## Preuves **Test de régression (`type:bug`)** : `categoryService.test.ts` reproduit A → B → A par `updateCategory` (A racine, B son enfant, A enregistrée sous B) : refus `cycle`, `BEGIN` puis `ROLLBACK`, rien d'écrit, l'arbre (`buildTree`) montre toujours A et B. Sur des lignes déjà en boucle (5 ↔ 3 avec 8 sous 5, puis 6 → 4 → 9 → 6), le diagnostic trouve le plus petit membre, `reattachCategory` se termine, casse la boucle (diagnostic vide ensuite) et `buildTree` montre de nouveau toute la branche. Réserve de la revue du plan : `reattachCategory` sur un membre d'une boucle de 2, puis de 3, se termine et casse la boucle (même fichier). **Mutations** : implémentation commitée avant la première ; une mutation à la fois, restaurée par `git checkout --`, `git status --short` vide après chacune ; vitest lancé par `node node_modules/vitest/vitest.mjs run --pool=threads <fichiers>` sous `timeout -s KILL 90` et `NODE_OPTIONS=--max-old-space-size=768` (les mutations des gardes de visités rendent les parcours infinis). | # | Fichier | Mutation | Tests rouges | rc | |---|---|---|---|---| | M1 | `categoryHierarchy.ts` | règle `self_parent` retirée | 2 | 1 | | M2 | `categoryHierarchy.ts` | règle `cycle` (parent dans la descendance) retirée | 3 | 1 | | M3 | `categoryHierarchy.ts` | règle `cycle` (parent sans profondeur) retirée | 1 | 1 | | M4 | `categoryHierarchy.ts` | règle `too_deep` retirée | 3 | 1 | | M5 | `categoryHierarchy.ts` | `fitsUnder` sans le niveau de la catégorie | 13 | 1 | | M6 | `categoryHierarchy.ts` | racine plus toujours acceptée | 1 | 1 | | M7 | `categoryService.ts` | règle `parent_has_transactions` retirée | 2 | 1 | | M8 | `categoryService.ts` | `updateCategory` : le nouveau parent garde `is_inputable` | 1 | 1 | | M9 | `categoryService.ts` | `reattachCategory` : le nouveau parent garde `is_inputable` | 1 | 1 | | M10 | `categoryService.ts` | parent inchangé vérifié aussi | 1 | 1 | | M11 | `categoryService.ts` | lecture par `getDb()` dans la transaction | 8 | 1 | | M12 | `categoryService.ts` | pas de `BEGIN` (hors transaction) | 8 | 1 | | M13 | `categoryService.ts` | garde de visités de `getCategoryDepth` retirée | worker à court de mémoire (« Worker exited unexpectedly », le parcours ne finit pas) | 1 | | M14 | `categoryService.ts` | boucle : rend les pas comptés au lieu d'`Infinity` | 1 | 1 | | M15 | `categoryHierarchy.ts` | garde de visités de `depthsOf` retirée | abandon V8 par manque de mémoire (le parcours ne finit pas) | -6 | | M16 | `categoryHierarchy.ts` | garde de visités de la descente retirée | worker à court de mémoire | 1 | | M17 | `categoryHierarchy.ts` | garde de visités de `findOrphans` retirée | 4 (`RangeError: Invalid array length` après ~5 s chacun) | 1 | | M18 | `categoryHierarchy.ts` | membre de boucle : le premier rencontré au lieu du plus petit `id` | 4 | 1 | | M19 | `categoryHierarchy.ts` | parent inactif traversé comme actif | 3 | 1 | | M20 | `parentOptions.ts` | un membre de boucle compte comme une racine | 3 | 1 | | M21 | `useBudget.ts` | ancienne règle de racine (`!c.parent_id`) | 2 | 1 | | M22 | `budgetService.ts` | ancienne règle de racine | 2 | 1 | | M23 | `useCategories.ts` | refus affiché par `e.message` | 3 | 1 | | M24 | `useCategories.ts` | pas de ligne de Journal | 2 | 1 | | M25 | `useCategories.ts` | arbre non rechargé après un rattachement | 2 | 1 | | M26 | `useCategories.ts` | une vérification tardive rouvre une fenêtre fermée | 1 | 1 | | M27 | `HierarchyCheckModal.tsx` | racine non présélectionnée | 3 | 1 | | M28 | `HierarchyCheckModal.tsx` | « Rattacher » ignore le parent choisi | 1 | 1 | | M29 | `HierarchyCheckModal.tsx` | options sans l'orpheline comme catégorie éditée | 1 | 1 | | M30 | `HierarchyCheckModal.tsx` | un parent qui n'est plus proposé est quand même transmis | 1 | 1 | | M31 | `CategoriesPage.tsx` | clé d'un refus affichée sans traduction | 1 | 1 | | M32 | `CategoriesPage.tsx` | bouton d'un autre style que « Réinitialiser » | 1 | 1 | | M33 | `CategoriesPage.tsx` | bouton actif pendant un enregistrement | 1 | 1 | | M34 | `useCategories.ts` | arbre non rechargé quand la vérification qui suit échoue | 1 | 1 | | R1 | `categoryService.ts` | (relecture) parent inactif plus vérifié comme disponible | 1 | 1 | | R3 | `categoryService.ts` | (relecture) le compte inclut les descendantes inactives | 1 | 1 | | R4 | `HierarchyCheckModal.tsx` | (relecture) nom d'une catégorie standard non traduit | 1 | 1 | | R5 | `categoryHierarchy.ts` | (relecture) orphelines non triées par `id` | 1 | 1 | 38 mutations, toutes rouges. M23 à M26 rejouées après `cb0e2d2` ; R1, R3, R4 et R5, de l'autorelecture, rejouées sur la tête `8a9d990` (R3 après l'ajout du test qui l'épingle). Aucun processus vitest resté actif, aucun fichier laissé dans l'arbre (le vidage mémoire de M15 est pris par apport, hors du dépôt). **Suites** : sonde rc=1 (`src/zz_tmp_probe.test.ts` volontairement rouge, supprimé) ; `npm run build` vert ; `npm test` 152 fichiers / 2458 tests verts (147 / 2375 sur la base). Rust : non concerné (aucun fichier Rust). ## Vérifications manuelles dues (prochaine release) Sur un profil de test, application fermée pour les modifications SQLite (une boucle ne peut plus se créer par l'interface depuis #423) : 1. En-tête de la page Catégories : « Vérifier la hiérarchie » entre « Tous les mots-clés » et « Réinitialiser », même style que « Réinitialiser », en thème clair et sombre ; grisé pendant un enregistrement. 2. Profil sain : le bouton ouvre la fenêtre, qui dit « Aucune catégorie orpheline : chaque catégorie est à sa place dans la hiérarchie. » ; « Fermer » la ferme. 3. Boucle : dans la base du profil, `UPDATE categories SET parent_id = <id d'un enfant> WHERE id = <id de son parent>` ; relancer. La branche a disparu de l'arbre, du sélecteur de catégorie des Transactions, de la grille Budget et des rapports. La fenêtre compte 1 orpheline, nommée (le membre de plus petit `id`), motif « Prise dans une boucle de parenté », « Racine (niveau supérieur) » présélectionnée. « Rattacher » : la branche revient dans l'arbre, les sélecteurs, la grille Budget et les rapports, avec ses transactions, ses mots-clés et ses montants de budget ; la fenêtre dit « aucune ». 4. Journal (Paramètres → Systèmes → Journaux) après le point 3 : une seule ligne `Categories: orphan category reattached (cycle), N categories back in the hierarchy`, sans nom de catégorie. 5. Dans la fenêtre, choisir un autre parent : la liste du combobox s'ouvre par-dessus la fenêtre, sans être coupée par la liste défilante des lignes (portail de #429), avec les parents du formulaire. « Rattacher » sous un parent qui a des transactions à son nom : message traduit en haut de la fenêtre (« Ce parent a des transactions à son nom… »), rien de changé. 6. Formulaire de catégorie : choisir comme parent une catégorie qui a des transactions à son nom, « Enregistrer » : message traduit dans le bandeau rouge au-dessus des panneaux, formulaire toujours ouvert, rien d'enregistré. Renommer cette même catégorie sans changer son parent : enregistré. 7. Parent désactivé : `UPDATE categories SET is_active = 0 WHERE id = <id d'un parent>` (sans remonter ses enfants) ; relancer. L'enfant s'affiche au premier niveau dans la grille Budget et le rapport Budget vs réel, avec ses montants ; la fenêtre le liste « Son parent a été supprimé » ; « Rattacher » sous un parent valide le replace. 8. Fenêtre, bandeau d'erreur, motifs et boutons lisibles en thème clair et sombre ; textes en français et en anglais (nombre au singulier et au pluriel). ## À regarder - La nouvelle sémantique de `depthsOf` (aucune profondeur dans ou sous une boucle) change ce que `buildParentOptions` propose dans ce cas ; épinglé par un test ajouté à `parentOptions.test.ts`, les tests existants inchangés. - `updateCategory` tient maintenant le verrou de `db.ts` pour toute sa lecture-vérification-écriture (`withTransaction`) au lieu d'un seul `execute`. - La limite connue de la racine toujours acceptée (branche de 4 niveaux possible après la réparation d'une boucle de 3 membres ou plus avec des catégories dessous). - `getCategoryDepth` compte encore un parent inactif comme un niveau (comportement d'avant, chemin de `createCategory` seulement), alors que la validation et l'arbre le traitent comme absent : plus strict d'un niveau, laissé tel quel. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 5 commits 2026-10-18 23:27:19 +00:00
src/utils/categoryHierarchy.ts holds the depth, height and descent walks
that buildParentOptions had inline, each with a set of the categories it
has seen, so a parent_id cycle ends the walk. A category caught in a cycle,
or under one, now has no depth instead of a made-up one, and the parent
list of the category form never offers it: a child placed there would be
hidden with it. The module also carries the orphan diagnostic, the parent
change refusal and the root rule that the service, the Categories page and
the budget use next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
updateCategory checks a new parent before writing: the category itself,
one of its descendants or a category caught in a cycle (cycle), a fourth
level counting its sub-tree (too_deep), a parent holding transactions of
its own (parent_has_transactions). The refusal is a CategoryHierarchyError
that the Categories page shows by the translation of its code. The reads
and the writes, the new parent's is_inputable included, run in one
transaction through its handle; an unchanged parent is not checked, so a
category stored before these rules can still be renamed.

getCategoryDepth keeps the ids it has seen and ends on a cycle.

A "Check hierarchy" button, between "All keywords" and "Re-initialize",
opens a window that lists the orphan categories (caught in a parent loop,
parent deleted, parent gone), one per group, with a new parent for each:
the root by default, or a parent of the category form's list.
reattachCategory applies the same rules; after it, the list and the tree
reload and the Journal gets one line with the reason and the number of
categories put back, no name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The budget grid and the Budget vs Actual report read active categories
only and started from the categories without a parent, so a category whose
parent was deactivated or no longer exists vanished from both, budget
amounts included. It now stands at the top level, as in the tree and the
category pickers (displaysAsRoot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG (Added: the "Check hierarchy" window; Fixed: the parent checks
at save time and the budget rows of a category whose parent is deleted or
gone), the Categories section of the user guide and its docs.* keys in
both languages, and docs/architecture.md: the categoryHierarchy module, the
diagnostic, the validation and its typed error, the window, the budget
root rule, and what stays out of scope (drag-and-drop, archive restore).

Resolves #393

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: reload the category tree even when the check after a reattachment fails (#393)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m13s
cb0e2d2536
The reattachment is committed before the orphan list is checked again: if
that check failed, the tree kept showing the categories as they were. The
tree now reloads in a finally block, and the failure shows in the window.

Resolves #393

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-10-18 23:27:33 +00:00
maximus added 1 commit 2026-10-18 23:28:29 +00:00
docs: name the budget as a reader of the hierarchy module (#393)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m6s
bad53fe85b
The architecture section said the module had three consumers; the walks
do, and the budget reads displaysAsRoot as well.

Resolves #393

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus added 1 commit 2026-10-18 23:29:34 +00:00
test: pin the reattachment count to active descendants (#393)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m9s
8a9d9909f9
A deactivated child of the reattached category is not counted in the
number of categories put back, which the Journal line reports.

Resolves #393

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Niveau : normal
Verdict : APPROVE

Autorelecture de l'auteur (worker /autopilot), pas une revue indépendante. Checklist de /pr-review appliquée au diff complet de la PR contre sa base (issue-429-category-combobox-portal), à la profondeur du niveau normal.

Niveau (Step 0bis, REVIEW.md de main)

Aucune ligne du tableau Haut n'est remplie : le code de src/** n'ajoute aucun appel réseau, ne change rien de ce qu'un appel envoie, n'ouvre aucune URL et ne touche aucun consentement (balayage du diff : ni fetch, ni invoke, ni URL, ni window.open) ; aucun fichier de licence, de profil, de migration, de manifeste, de lock, de workflow ni de configuration ; aucun fichier ajouté ou supprimé à la racine. docs/architecture.md reste au moins normal ; src/i18n/locales/*.json et le code sont normaux ; CHANGELOG*.md et docs/guide-utilisateur.md sont bas. La PR est donc normale.

Ce qui a été rejoué

  • npm run build vert ; npm test : 152 fichiers, 2458 tests verts, après une sonde rouge (rc=1).
  • 38 mutations, toutes rouges (tableau du corps de la PR) : chaque règle de la validation (self_parent, les deux formes de cycle, too_deep, parent_has_transactions), la racine toujours acceptée, is_inputable du nouveau parent, la transaction (pas de BEGIN, lecture par getDb() dans le rappel), le parent inchangé non vérifié, les quatre gardes de visités (parcours infinis : workers à court de mémoire ou RangeError), le plus petit id d'une boucle, la règle de racine du budget (grille et Budget vs réel), la clé traduite du hook et de la page, la ligne de Journal, les rechargements, la fenêtre (racine présélectionnée, parent choisi transmis, options de la ligne, choix périmé), le bouton (place, style, état pendant un enregistrement). Quatre mutations de relecture ajoutées (R1, R3, R4, R5).
  • Règles des zones de REVIEW.md touchées : Migrations, profils et données (lectures et écritures sous un seul withTransaction, par son handle, prouvé par une base factice dont getDb() lève pendant la transaction ; is_inputable lié par toSqlFlag ; aucune migration ; seule categories est écrite, que COUNTERS_SQL ne lit pas, donc pas de notifyDataChanged), Vie privée (la ligne de Journal ne porte qu'un code de motif et un nombre, ni nom, ni chemin), i18n et documentation (clés FR et EN, garde locales.keys.test.ts verte, CHANGELOG des deux langues, guide §6 et docs.categories, docs/architecture.md).
  • Test de régression type:bug présent : A → B → A refusé par updateCategory ; boucles de 2 et de 3 membres trouvées par le diagnostic, cassées par reattachCategory, branche de nouveau visible dans buildTree.

Constats de l'autorelecture (tous corrigés dans la PR)

  1. src/hooks/useCategories.ts:432 — après un rattachement écrit, un échec de la seconde vérification sautait le rechargement de l'arbre : rechargement passé dans un finally (cb0e2d2), test ajouté, mutation M34 rouge.
  2. docs/architecture.md:274 — « Trois consommateurs » oubliait le budget, qui lit displaysAsRoot : phrase corrigée (bad53fe).
  3. src/services/categoryService.ts:249 — le nombre rendu par reattachCategory (repris dans la ligne de Journal) n'était pas épinglé aux seules descendantes actives : test complété (8a9d990), mutation R3 rouge.

Remarques non bloquantes

  • src/utils/categoryHierarchy.ts:164 — la racine est toujours acceptée : après la réparation d'une boucle de 3 membres ou plus avec des catégories dessous, une branche peut garder 4 niveaux (l'arbre l'affiche, la grille Budget s'arrête au troisième). Inatteignable depuis #423, écrit dans la PR.
  • src/services/categoryService.ts:155 — getCategoryDepth compte encore un parent inactif comme un niveau (comportement d'avant, chemin de createCategory seulement) : plus strict d'un niveau que l'arbre, laissé tel quel.
  • src/components/categories/HierarchyCheckModal.tsx:140 — suggestion : les boutons « Rattacher » de chaque ligne ont le même nom accessible ; un aria-label qui nomme la catégorie aiderait un lecteur d'écran (le combobox qui le précède est déjà nommé). Pas d'Échap ni de piège de focus, comme la fenêtre « Réinitialiser » de la page (#451 suit ce motif).
  • Les vérifications dans l'application (bouton, fenêtre, réparation d'une boucle créée dans SQLite, message du formulaire, budget, thèmes, FR/EN) sont listées dans le corps de la PR, pour la prochaine release.

Rien ne bloque. Label de l'issue laissé à status:review : cette autorelecture n'est pas indépendante.

Niveau : normal Verdict : APPROVE **Autorelecture de l'auteur (worker /autopilot), pas une revue indépendante.** Checklist de `/pr-review` appliquée au diff complet de la PR contre sa base (`issue-429-category-combobox-portal`), à la profondeur du niveau normal. ## Niveau (Step 0bis, `REVIEW.md` de `main`) Aucune ligne du tableau Haut n'est remplie : le code de `src/**` n'ajoute aucun appel réseau, ne change rien de ce qu'un appel envoie, n'ouvre aucune URL et ne touche aucun consentement (balayage du diff : ni `fetch`, ni `invoke`, ni URL, ni `window.open`) ; aucun fichier de licence, de profil, de migration, de manifeste, de lock, de workflow ni de configuration ; aucun fichier ajouté ou supprimé à la racine. `docs/architecture.md` reste au moins normal ; `src/i18n/locales/*.json` et le code sont normaux ; `CHANGELOG*.md` et `docs/guide-utilisateur.md` sont bas. La PR est donc **normale**. ## Ce qui a été rejoué - `npm run build` vert ; `npm test` : 152 fichiers, 2458 tests verts, après une sonde rouge (rc=1). - 38 mutations, toutes rouges (tableau du corps de la PR) : chaque règle de la validation (`self_parent`, les deux formes de `cycle`, `too_deep`, `parent_has_transactions`), la racine toujours acceptée, `is_inputable` du nouveau parent, la transaction (pas de `BEGIN`, lecture par `getDb()` dans le rappel), le parent inchangé non vérifié, les quatre gardes de visités (parcours infinis : workers à court de mémoire ou `RangeError`), le plus petit `id` d'une boucle, la règle de racine du budget (grille et Budget vs réel), la clé traduite du hook et de la page, la ligne de Journal, les rechargements, la fenêtre (racine présélectionnée, parent choisi transmis, options de la ligne, choix périmé), le bouton (place, style, état pendant un enregistrement). Quatre mutations de relecture ajoutées (R1, R3, R4, R5). - Règles des zones de `REVIEW.md` touchées : **Migrations, profils et données** (lectures et écritures sous un seul `withTransaction`, par son handle, prouvé par une base factice dont `getDb()` lève pendant la transaction ; `is_inputable` lié par `toSqlFlag` ; aucune migration ; seule `categories` est écrite, que `COUNTERS_SQL` ne lit pas, donc pas de `notifyDataChanged`), **Vie privée** (la ligne de Journal ne porte qu'un code de motif et un nombre, ni nom, ni chemin), **i18n et documentation** (clés FR et EN, garde `locales.keys.test.ts` verte, CHANGELOG des deux langues, guide §6 et `docs.categories`, `docs/architecture.md`). - Test de régression `type:bug` présent : A → B → A refusé par `updateCategory` ; boucles de 2 et de 3 membres trouvées par le diagnostic, cassées par `reattachCategory`, branche de nouveau visible dans `buildTree`. ## Constats de l'autorelecture (tous corrigés dans la PR) 1. `src/hooks/useCategories.ts:432` — après un rattachement écrit, un échec de la seconde vérification sautait le rechargement de l'arbre : rechargement passé dans un `finally` (`cb0e2d2`), test ajouté, mutation M34 rouge. 2. `docs/architecture.md:274` — « Trois consommateurs » oubliait le budget, qui lit `displaysAsRoot` : phrase corrigée (`bad53fe`). 3. `src/services/categoryService.ts:249` — le nombre rendu par `reattachCategory` (repris dans la ligne de Journal) n'était pas épinglé aux seules descendantes actives : test complété (`8a9d990`), mutation R3 rouge. ## Remarques non bloquantes - `src/utils/categoryHierarchy.ts:164` — la racine est toujours acceptée : après la réparation d'une boucle de 3 membres ou plus avec des catégories dessous, une branche peut garder 4 niveaux (l'arbre l'affiche, la grille Budget s'arrête au troisième). Inatteignable depuis #423, écrit dans la PR. - `src/services/categoryService.ts:155` — `getCategoryDepth` compte encore un parent inactif comme un niveau (comportement d'avant, chemin de `createCategory` seulement) : plus strict d'un niveau que l'arbre, laissé tel quel. - `src/components/categories/HierarchyCheckModal.tsx:140` — suggestion : les boutons « Rattacher » de chaque ligne ont le même nom accessible ; un `aria-label` qui nomme la catégorie aiderait un lecteur d'écran (le combobox qui le précède est déjà nommé). Pas d'Échap ni de piège de focus, comme la fenêtre « Réinitialiser » de la page (#451 suit ce motif). - Les vérifications dans l'application (bouton, fenêtre, réparation d'une boucle créée dans SQLite, message du formulaire, budget, thèmes, FR/EN) sont listées dans le corps de la PR, pour la prochaine release. Rien ne bloque. Label de l'issue laissé à `status:review` : cette autorelecture n'est pas indépendante.
Author
Owner

CI lue par l'orchestrateur du run /autopilot du 2026-10-18 : check-frontend vert sur la tête 8a9d990 (run 582).

  • vitest : 152 fichiers et 2458 tests, tous verts.
  • Les fichiers de cette PR ont tourné :
    • categoryService.test.ts (19 tests), qui contient le test de régression A → B → A ;
    • categoryHierarchy.test.ts (27) ;
    • useCategories.test.ts (11) ;
    • HierarchyCheckModal.render.test.tsx (9) et HierarchyCheckModal.wiring.test.ts (5) ;
    • budgetService.test.ts (16) ;
    • parentOptions.test.ts (15), qui reste vert après le déplacement de depthsOf et subtreeHeight.
  • La garde locales.keys.test.ts (8) est verte : les 28 clés ajoutées existent en français et en anglais.
  • check-rust ne s'applique pas à ce maillon (aucun chemin Rust touché).
CI lue par l'orchestrateur du run `/autopilot` du 2026-10-18 : `check-frontend` **vert** sur la tête `8a9d990` (run 582). - vitest : 152 fichiers et 2458 tests, tous verts. - Les fichiers de cette PR ont tourné : - `categoryService.test.ts` (19 tests), qui contient le test de régression A → B → A ; - `categoryHierarchy.test.ts` (27) ; - `useCategories.test.ts` (11) ; - `HierarchyCheckModal.render.test.tsx` (9) et `HierarchyCheckModal.wiring.test.ts` (5) ; - `budgetService.test.ts` (16) ; - `parentOptions.test.ts` (15), qui reste vert après le déplacement de `depthsOf` et `subtreeHeight`. - La garde `locales.keys.test.ts` (8) est verte : les 28 clés ajoutées existent en français et en anglais. - `check-rust` ne s'applique pas à ce maillon (aucun chemin Rust touché).
Author
Owner

Niveau : normal

Verdict : APPROVE

Résumé. Le module pur categoryHierarchy.ts est correct : chaque remontée et chaque descente garde ses visités, les quatre refus sont testés, la validation et l'écriture passent par un seul withTransaction et son handle, et le diagnostic lit bien toutes les catégories. Aucun défaut bloquant. La revue relève un cas réel où la fenêtre perd les catégories qu'elle doit rattacher (après un glisser-déposer), cinq épingles manquantes sur du code correct, et une question d'intention sur is_inputable du nouveau parent.

Niveau calculé sur les 24 fichiers : aucun ne remplit une ligne du tableau Haut. Le code livré n'ajoute ni appel réseau, ni URL, ni consentement (la ligne de Journal reste locale et ne nomme aucune catégorie), et aucun fichier n'est ajouté à la racine ni dans src-tauri/. docs/architecture.md et les locales sont normaux, les changelogs et le guide bas.

Ce que la revue a exécuté

Clone détaché hors du dépôt sur la tête 8a9d990, npm ci :

  • sonde volontairement rouge : rc=1 ;
  • npm run build : rc=0 ;
  • npm test : 152 fichiers, 2458 tests, rc=0 (les chiffres du corps de la PR) ;
  • CI : check-frontend run 582 vert sur 8a9d990 (le journal montre le checkout de 8a9d990 lui-même, 152 fichiers, 2458 tests) ; check-rust non déclenché, aucun fichier sous ses chemins.

Mutations du relecteur

Une à la fois, suite complète, fichier restauré par git checkout --, arbre propre après chacune.

# Fichier Mutation Tests rouges
RM1 categoryHierarchy.ts tête d'une boucle cherchée sur tout le chemin, ce qui pend dessous compris 0
RM2 useCategories.ts START_REATTACH garde le refus précédent dans la fenêtre 0
RM3 categoryHierarchy.ts too_deep mesure le sous-arbre du nouveau parent au lieu de celui de la catégorie 3
RM4 categoryHierarchy.ts sous une catégorie sans profondeur déjà traitée, une catégorie devient racine 3
RM5 categoryService.ts parent_has_transactions compte les transactions de la catégorie déplacée 2
RM6 categoryService.ts inTransaction avale l'erreur après le ROLLBACK 8
RM7 categoryService.ts validation sur toutes les lignes (catégories inactives comptées comme niveaux) 0
RM8 categoryHierarchy.ts parent désactivé rapporté comme introuvable 3
RM9 useCategories.ts ligne de Journal avec un compte fixe 1
RM10 HierarchyCheckModal.tsx revenir à « Racine » ne fait rien 1
RM11 CategoriesPage.tsx la page passe state.tree (racines seules) à la fenêtre 0
RM12 categoryService.ts updateCategory marque aussi un parent inchangé 1
RM13 categoryHierarchy.ts règle cycle sur les descendants du parent (ses ancêtres) 3
RM14 categoryService.ts updateCategory vérifie le parent à chaque enregistrement 1
RM15 categoryHierarchy.ts displaysAsRoot montre en racine une catégorie qui est son propre parent 0

Dix rouges sur quinze. Les cinq survivantes portent sur du code correct : ce sont des épingles manquantes (suggestion 2), pas des défauts.

Suggestions (non bloquantes)

  1. Après un glisser-déposer, la fenêtre perd les membres d'une boucle. moveCategory envoie SET_TREE (useCategories.ts:383), dont le reducer remplace state.categories par flattenTreeToCategories(tree) (:149) : seulement ce qu'une racine atteint, donc aucun membre d'une boucle, et rien ne recharge la liste quand l'écriture réussit. La page passe cette liste à la fenêtre (CategoriesPage.tsx:160). Sonde sur la copie (lignes 1, 10 sous 1, 2, et 3 ↔ 5) : avant, state.categories vaut [1, 2, 3, 5, 10] ; après moveCategory(2, null, 0) puis checkHierarchy(), [1, 2, 10], alors que le diagnostic rend toujours { id: 3, reason: "cycle" }. La ligne de 3 s'affiche alors « — » (nameOf, et l'aria-label devient « Nouveau parent de — »), et ses parents proposés sont [1, 2, 10] au lieu de [1, 2] : 10, de niveau 1, est offert alors que 5 pend sous 3, et le service le refuse en too_deep. Aucune donnée en jeu (le service revalide, la racine choisie par défaut répare), mais dans ce cas, atteignable depuis l'interface, la fenêtre ne donne plus le nom de l'orpheline. Correctif d'une ligne, à poser en tête de pile : recharger les catégories dans checkHierarchy (par exemple await loadCategories() avant le diagnostic), avec un test du hook « glisser-déposer, puis vérifier » qui épingle aussi RM11.
  2. Épingles manquantes (code correct ; tests en tête de pile ou issue de suivi, sans nouveau tour de revue) :
    • RM1 : aucun test n'a une catégorie d'id plus petit sous une boucle. findOrphans([r(2, 5), r(5, 8), r(8, 5), r(1, null)]) doit rendre [{ id: 5, reason: "cycle" }] ; sous la mutation il rend 2, dont le rattachement laisse 5 ↔ 8 cachés.
    • RM2 : un refus suivi d'un rattachement réussi doit vider le message de la fenêtre ; sous la mutation, « Ce parent a des transactions… » reste affiché après le succès.
    • RM7 : la règle « profondeur comptée sur les catégories actives » n'est pas épinglée au service. Exemple : 3 sous un parent désactivé (affichée en racine dans l'arbre, proposée comme racine par la fenêtre et le formulaire), 1 racine avec un enfant 10 : updateCategory(1, form(3)) doit passer ; sous la mutation, refus too_deep.
    • RM11 : aucun test de la page ne vérifie la liste qu'elle passe à la fenêtre (le mock rend categories: []).
    • RM15 : rien n'épingle qu'une catégorie qui est son propre parent reste cachée du budget comme de l'arbre (le test du budget couvre une boucle à deux membres seulement).
  3. Question d'intention : is_inputable du nouveau parent. Avant cette PR, updateCategory ne touchait pas le nouveau parent ; il passe désormais à is_inputable = 0, au formulaire comme dans la fenêtre (markAsParent), et le refus parent_has_transactions ne protège que les transactions. Sonde sur buildBudgetYearRows : une racine P avec 100 de budget à son nom et une feuille C à 40 ; une fois C sous P et P non saisissable, la grille montre P en sous-total de 40 et les 100 de P en sortent (la ligne reste dans budget_entries). De même, les mots-clés actifs de P continuent de classer des transactions vers P (categorizeDescription et categorizeBatch lisent tous les mots-clés actifs, sans regarder is_inputable), et le Budget vs réel ne montre la ligne « (direct) » d'un parent que s'il est saisissable. C'est le comportement de createCategory depuis toujours, que cette PR étend au changement de parent. À trancher par Max : élargir le refus (montants de budget, mots-clés actifs), ou l'accepter et ouvrir une issue de suivi.

Vérifié sans constat

  • Transactions : updateCategory et reattachCategory lisent et écrivent par le handle de withTransaction, avec BEGIN/COMMIT/ROLLBACK comme les autres services et aucun getDb() dans le rappel ; le faux de categoryService.test.ts lève sur getDb() pendant une transaction.
  • Booléens liés en 1/0 (toSqlFlag, 0 littéral dans markAsParent) ; aucune écriture d'une table lue par COUNTERS_SQL, seule categories est écrite.
  • Parcours : depthsOf, levelsBelow et findOrphans se terminent sur toute boucle (ensemble de visités, chaque tour ajoute au moins une catégorie) ; une boucle qui passe par une catégorie désactivée ressort en inactive_parent, comme l'arbre l'affiche.
  • buildParentOptions rend la même liste qu'avant dans un profil sans boucle : depth + 1 + height <= 2 équivaut à l'ancien depth <= 1 - height.
  • Budget : les deux constructeurs lisent getAllActiveCategories ; une catégorie à parent désactivé passe au premier niveau sans double compte, le seau du parent absent n'étant jamais parcouru.
  • Vie privée : la ligne de Journal donne le motif et un nombre, sans nom ni chemin ; aucun appel réseau, aucune dépendance, aucun fichier Rust, aucune migration.
  • i18n et docs : clés présentes dans les deux langues (garde de parité de #453 verte dans la pile), common.close existe ; docs.categories et le guide suivent (une fonctionnalité, une étape, deux astuces) ; CHANGELOG bilingue (Ajouté, Corrigé) ; architecture.md : 20 utilitaires recomptés dans src/utils.
  • Fermeture : la timeline de #393 porte un pull_ref closes de cette PR, et les commits portent Resolves #393 : #393 se ferme au merge quel que soit le chemin, ce que la PR veut (ses vérifications manuelles visent la prochaine release).
  • Sept commits au format conventionnel ; revue faite contre la base de la pile (#471).

Actions faites

  • Copie hors dépôt et fichiers /tmp/pr472-* supprimés en fin de revue.
  • #393 : status:review → status:approved.
Niveau : normal **Verdict : APPROVE** **Résumé.** Le module pur `categoryHierarchy.ts` est correct : chaque remontée et chaque descente garde ses visités, les quatre refus sont testés, la validation et l'écriture passent par un seul `withTransaction` et son handle, et le diagnostic lit bien toutes les catégories. Aucun défaut bloquant. La revue relève un cas réel où la fenêtre perd les catégories qu'elle doit rattacher (après un glisser-déposer), cinq épingles manquantes sur du code correct, et une question d'intention sur `is_inputable` du nouveau parent. Niveau calculé sur les 24 fichiers : aucun ne remplit une ligne du tableau Haut. Le code livré n'ajoute ni appel réseau, ni URL, ni consentement (la ligne de Journal reste locale et ne nomme aucune catégorie), et aucun fichier n'est ajouté à la racine ni dans `src-tauri/`. `docs/architecture.md` et les locales sont normaux, les changelogs et le guide bas. ### Ce que la revue a exécuté Clone détaché hors du dépôt sur la tête `8a9d990`, `npm ci` : - sonde volontairement rouge : rc=1 ; - `npm run build` : rc=0 ; - `npm test` : 152 fichiers, 2458 tests, rc=0 (les chiffres du corps de la PR) ; - CI : `check-frontend` run 582 vert sur `8a9d990` (le journal montre le checkout de `8a9d990` lui-même, 152 fichiers, 2458 tests) ; `check-rust` non déclenché, aucun fichier sous ses chemins. ### Mutations du relecteur Une à la fois, suite complète, fichier restauré par `git checkout --`, arbre propre après chacune. | # | Fichier | Mutation | Tests rouges | |---|---|---|---| | RM1 | `categoryHierarchy.ts` | tête d'une boucle cherchée sur tout le chemin, ce qui pend dessous compris | **0** | | RM2 | `useCategories.ts` | `START_REATTACH` garde le refus précédent dans la fenêtre | **0** | | RM3 | `categoryHierarchy.ts` | `too_deep` mesure le sous-arbre du nouveau parent au lieu de celui de la catégorie | 3 | | RM4 | `categoryHierarchy.ts` | sous une catégorie sans profondeur déjà traitée, une catégorie devient racine | 3 | | RM5 | `categoryService.ts` | `parent_has_transactions` compte les transactions de la catégorie déplacée | 2 | | RM6 | `categoryService.ts` | `inTransaction` avale l'erreur après le `ROLLBACK` | 8 | | RM7 | `categoryService.ts` | validation sur toutes les lignes (catégories inactives comptées comme niveaux) | **0** | | RM8 | `categoryHierarchy.ts` | parent désactivé rapporté comme introuvable | 3 | | RM9 | `useCategories.ts` | ligne de Journal avec un compte fixe | 1 | | RM10 | `HierarchyCheckModal.tsx` | revenir à « Racine » ne fait rien | 1 | | RM11 | `CategoriesPage.tsx` | la page passe `state.tree` (racines seules) à la fenêtre | **0** | | RM12 | `categoryService.ts` | `updateCategory` marque aussi un parent inchangé | 1 | | RM13 | `categoryHierarchy.ts` | règle `cycle` sur les descendants du parent (ses ancêtres) | 3 | | RM14 | `categoryService.ts` | `updateCategory` vérifie le parent à chaque enregistrement | 1 | | RM15 | `categoryHierarchy.ts` | `displaysAsRoot` montre en racine une catégorie qui est son propre parent | **0** | Dix rouges sur quinze. Les cinq survivantes portent sur du code correct : ce sont des épingles manquantes (suggestion 2), pas des défauts. ### Suggestions (non bloquantes) 1. **Après un glisser-déposer, la fenêtre perd les membres d'une boucle.** `moveCategory` envoie `SET_TREE` (`useCategories.ts:383`), dont le reducer remplace `state.categories` par `flattenTreeToCategories(tree)` (`:149`) : seulement ce qu'une racine atteint, donc aucun membre d'une boucle, et rien ne recharge la liste quand l'écriture réussit. La page passe cette liste à la fenêtre (`CategoriesPage.tsx:160`). Sonde sur la copie (lignes 1, 10 sous 1, 2, et 3 ↔ 5) : avant, `state.categories` vaut [1, 2, 3, 5, 10] ; après `moveCategory(2, null, 0)` puis `checkHierarchy()`, [1, 2, 10], alors que le diagnostic rend toujours `{ id: 3, reason: "cycle" }`. La ligne de 3 s'affiche alors « — » (`nameOf`, et l'`aria-label` devient « Nouveau parent de — »), et ses parents proposés sont [1, 2, 10] au lieu de [1, 2] : 10, de niveau 1, est offert alors que 5 pend sous 3, et le service le refuse en `too_deep`. Aucune donnée en jeu (le service revalide, la racine choisie par défaut répare), mais dans ce cas, atteignable depuis l'interface, la fenêtre ne donne plus le nom de l'orpheline. Correctif d'une ligne, à poser en tête de pile : recharger les catégories dans `checkHierarchy` (par exemple `await loadCategories()` avant le diagnostic), avec un test du hook « glisser-déposer, puis vérifier » qui épingle aussi RM11. 2. **Épingles manquantes** (code correct ; tests en tête de pile ou issue de suivi, sans nouveau tour de revue) : - RM1 : aucun test n'a une catégorie d'`id` plus petit sous une boucle. `findOrphans([r(2, 5), r(5, 8), r(8, 5), r(1, null)])` doit rendre `[{ id: 5, reason: "cycle" }]` ; sous la mutation il rend 2, dont le rattachement laisse 5 ↔ 8 cachés. - RM2 : un refus suivi d'un rattachement réussi doit vider le message de la fenêtre ; sous la mutation, « Ce parent a des transactions… » reste affiché après le succès. - RM7 : la règle « profondeur comptée sur les catégories actives » n'est pas épinglée au service. Exemple : 3 sous un parent désactivé (affichée en racine dans l'arbre, proposée comme racine par la fenêtre et le formulaire), 1 racine avec un enfant 10 : `updateCategory(1, form(3))` doit passer ; sous la mutation, refus `too_deep`. - RM11 : aucun test de la page ne vérifie la liste qu'elle passe à la fenêtre (le mock rend `categories: []`). - RM15 : rien n'épingle qu'une catégorie qui est son propre parent reste cachée du budget comme de l'arbre (le test du budget couvre une boucle à deux membres seulement). 3. **Question d'intention : `is_inputable` du nouveau parent.** Avant cette PR, `updateCategory` ne touchait pas le nouveau parent ; il passe désormais à `is_inputable = 0`, au formulaire comme dans la fenêtre (`markAsParent`), et le refus `parent_has_transactions` ne protège que les transactions. Sonde sur `buildBudgetYearRows` : une racine P avec 100 de budget à son nom et une feuille C à 40 ; une fois C sous P et P non saisissable, la grille montre P en sous-total de 40 et les 100 de P en sortent (la ligne reste dans `budget_entries`). De même, les mots-clés actifs de P continuent de classer des transactions vers P (`categorizeDescription` et `categorizeBatch` lisent tous les mots-clés actifs, sans regarder `is_inputable`), et le Budget vs réel ne montre la ligne « (direct) » d'un parent que s'il est saisissable. C'est le comportement de `createCategory` depuis toujours, que cette PR étend au changement de parent. À trancher par Max : élargir le refus (montants de budget, mots-clés actifs), ou l'accepter et ouvrir une issue de suivi. ### Vérifié sans constat - Transactions : `updateCategory` et `reattachCategory` lisent et écrivent par le handle de `withTransaction`, avec `BEGIN`/`COMMIT`/`ROLLBACK` comme les autres services et aucun `getDb()` dans le rappel ; le faux de `categoryService.test.ts` lève sur `getDb()` pendant une transaction. - Booléens liés en 1/0 (`toSqlFlag`, `0` littéral dans `markAsParent`) ; aucune écriture d'une table lue par `COUNTERS_SQL`, seule `categories` est écrite. - Parcours : `depthsOf`, `levelsBelow` et `findOrphans` se terminent sur toute boucle (ensemble de visités, chaque tour ajoute au moins une catégorie) ; une boucle qui passe par une catégorie désactivée ressort en `inactive_parent`, comme l'arbre l'affiche. - `buildParentOptions` rend la même liste qu'avant dans un profil sans boucle : `depth + 1 + height <= 2` équivaut à l'ancien `depth <= 1 - height`. - Budget : les deux constructeurs lisent `getAllActiveCategories` ; une catégorie à parent désactivé passe au premier niveau sans double compte, le seau du parent absent n'étant jamais parcouru. - Vie privée : la ligne de Journal donne le motif et un nombre, sans nom ni chemin ; aucun appel réseau, aucune dépendance, aucun fichier Rust, aucune migration. - i18n et docs : clés présentes dans les deux langues (garde de parité de #453 verte dans la pile), `common.close` existe ; `docs.categories` et le guide suivent (une fonctionnalité, une étape, deux astuces) ; CHANGELOG bilingue (Ajouté, Corrigé) ; `architecture.md` : 20 utilitaires recomptés dans `src/utils`. - Fermeture : la timeline de #393 porte un `pull_ref` `closes` de cette PR, et les commits portent `Resolves #393` : #393 se ferme au merge quel que soit le chemin, ce que la PR veut (ses vérifications manuelles visent la prochaine release). - Sept commits au format conventionnel ; revue faite contre la base de la pile (#471). ### Actions faites - Copie hors dépôt et fichiers `/tmp/pr472-*` supprimés en fin de revue. - #393 : `status:review` → `status:approved`.
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m9s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin issue-393-category-hierarchy:issue-393-category-hierarchy
git checkout issue-393-category-hierarchy

Merge

Merge the changes and update on Forgejo.
git checkout issue-429-category-combobox-portal
git merge --no-ff issue-393-category-hierarchy
git checkout issue-429-category-combobox-portal
git merge --ff-only issue-393-category-hierarchy
git checkout issue-393-category-hierarchy
git rebase issue-429-category-combobox-portal
git checkout issue-429-category-combobox-portal
git merge --no-ff issue-393-category-hierarchy
git checkout issue-429-category-combobox-portal
git merge --squash issue-393-category-hierarchy
git checkout issue-429-category-combobox-portal
git merge --ff-only issue-393-category-hierarchy
git checkout issue-429-category-combobox-portal
git merge issue-393-category-hierarchy
git push origin issue-429-category-combobox-portal
Sign in to join this conversation.
No reviewers
No milestone
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#472
No description provided.