feat(onboarding): Transactions page step card and guided entry #405

Merged
maximus merged 2 commits from issue-399-onboarding-transactions-card into main 2026-10-09 01:10:48 +00:00
Owner

Resolves #399

Generated autonomously by /autopilot run of 2026-10-08

PR empilée sur #404 (elle-même sur #403 puis #402) : elle ne montre que son propre diff.

Ce qui change pour l'utilisateur

  • Quand l'étape suivante du parcours est « Classer une transaction à la main », la page Transactions affiche une carte de démarrage à la place du bandeau : « Étape 5 sur 6 · Classer une transaction à la main », une phrase qui dit quoi faire, et un contour qui pulse autour de la catégorie de la première transaction affichée.
  • Le lien de cette étape (bandeau, liste dépliée, « Étape suivante » de la carte Import) ouvre la page sur les transactions non catégorisées, toutes dates confondues : le filtre par défaut part du 1er janvier, et en janvier les relevés d'exemple tombaient l'année précédente. S'il n'en reste aucune à classer, la page montre toutes les transactions, une seule fois, et la phrase de la carte suit.
  • Phrase avec le filtre : « Seules les transactions non catégorisées sont affichées. Cliquez sur la catégorie de l'une d'elles… » ; sans le filtre : « Cliquez sur la catégorie d'une transaction… ». Le libellé du filtre vient de sa propre clé, en minuscules dans la phrase.
  • Classer une transaction coche l'étape en direct : la carte reste, annonce « Fait. » et propose « Saisir un premier budget », ou la fin du parcours en édition Gratuite. L'anneau disparaît.
  • Une visite ordinaire de la page garde les filtres par défaut (depuis le 1er janvier, toutes les catégories).

Technique

  • useTransactions(initialFilters?) : useReducer(transactionsReducer, initialFilters, initTransactionsState), filtres de départ posés sur les défauts et lus au montage seulement. Le reducer reste exporté ; nouvel état guidedFallback.
  • GUIDED_ENTRY_FILTERS = { dateFrom: null, uncategorizedOnly: true }, que la page passe quand readOnboardingNavState(location.state).guided est vrai (seule lecture de l'état de navigation, CWE-476).
  • Repli : sur SET_PAGE_RESULT avec totalCount === 0 et guidedFallback, le reducer retire uncategorizedOnly (dateFrom nul gardé), remet la page à 1, laisse isLoading vrai et crée un nouvel objet filters : l'effet existant sur les filtres relit, après son attente de 300 ms. La première page, quel que soit son compte, et tout SET_FILTER désarment le repli : une seule fois, pas de boucle, jamais un filtre de l'utilisateur retiré dans son dos. Prédicat inchangé (getTransactionPage), pas de compteur uncategorized.
  • transactionsCardModel(filters, checklist) (onboardingPath.ts) : texte uncategorized (avec labels: { filter: "transactions.filters.uncategorized" }) ou all, cible firstRowCategory ; { text: null, done: true, target: null } une fois l'étape cochée. CARD_PAGES reçoit /transactions.
  • TransactionTable reçoit onboardingTarget?: boolean : un enveloppeur div.rounded-lg autour du CategoryCombobox de chaque ligne, et seul celui de la première porte data-onboarding-target="transaction-category" et data-onboarding-highlight. CategoryCombobox ne change pas.
  • TransactionsPage rend OnboardingStepCard en premier enfant, avant son en-tête, et ne passe la cible que si la carte est affichée (showsStepCard), comme la page Import.
  • i18n : formateur lowercase enregistré dans src/i18n/config.ts ; les phrases FR et EN écrivent {{filter, lowercase}}.
  • CHANGELOG FR et EN (Ajouté) ; docs/architecture.md (ligne useTransactions, section Internationalisation). Aucun changement Rust, aucune migration.

Preuves

  • npm run build vert (tsc + vite).
  • npx vitest run : 1814 → 1844 tests verts (101 → 103 fichiers).
    • useTransactions.test.ts (+8) : filtres de départ, visite ordinaire, repli unique, pas de boucle, filtre de l'utilisateur jamais retiré.
    • onboardingPath.test.ts (+8) : CARD_PAGES, transactionsCardModel (deux textes, « Fait. », étape passée), et les deux phrases résolues par la vraie instance i18n, en FR et en EN, contre le texte exact de l'issue (filtre en minuscules).
    • TransactionTable.render.test.tsx (nouveau, +4) : exactement un élément mis en évidence, l'enveloppeur de la catégorie de la première ligne, sans les boutons mot-clé et fractionnement.
    • TransactionsPage.render.test.tsx (nouveau, +10) : page rendue avec le vrai useTransactions ; entrée guidée sans borne de date, visite ordinaire, état de navigation malformé, carte avant le titre, une seule cible, aucune cible sans carte, « Fait. » puis l'étape Budget, fin en Gratuite.
    • AppShell.render.test.tsx : /transactions sort de la table « bandeau tant qu'aucune carte n'est livrée » et gagne son test de carte, comme /import dans #404.
  • Passe de mutations sur l'arbre commité, une à la fois, restaurée par git checkout ; 5 fichiers de test concernés, 108 tests verts avant chaque mutation :
# Mutation Rouges
M1 la page ignore guided (aucun filtre de départ) 2
M2 pas de repli (totalCount < 0) 1
M3 la première page ne désarme pas le repli (boucle) 3
M4 une visite ordinaire arme le repli 2
M5 anneau sur chaque ligne (index >= 0) 3
M6 textes inversés (!uncategorizedOnly) 5
M7 CARD_PAGES sans /transactions 2
M8 « Fait. » lu sur l'étape Budget 3

8/8 rouges, arbre propre après chaque restauration.

Décisions

12 décisions consignées dans le journal du run ; les principales :

  • [MEDIUM] transactionsCardModel prend aussi la liste (checklist), comme importCardModel : sans elle, pas de done, donc ni « Fait. » ni « Étape suivante » (critère d'acceptation). La signature de l'issue ne portait que { uncategorizedOnly }.
  • [MEDIUM] Minuscule de {{filter}} par un formateur i18next lowercase (config.ts, hors de la liste de fichiers de l'issue) : la phrase de chaque langue décide de la casse, et le libellé reste interpolé depuis sa clé. Écartés : la minuscule dans StepCard (composant de #404) ; une clé recopiée en minuscules.
  • [MEDIUM] Repli armé quand les filtres de départ demandent « non catégorisées », désarmé par la première page et par tout SET_FILTER ; relecture par l'effet existant sur les filtres (300 ms), « Chargement… » entre-temps. Écarté : un jeton de relecture immédiate.
  • [MEDIUM] Entrée guidée lue au montage seulement : un lien guidé suivi depuis /transactions même (« Reprendre ») garde les filtres en cours, et la phrase de la carte suit le filtre réel. L'issue dit « démarre avec ».
  • [MEDIUM] Enveloppeur sur chaque ligne, attributs sur la première seulement : l'anneau qui apparaît ou disparaît ne remonte jamais le combobox.
  • [MEDIUM] Cible portée par le modèle (target, null après « Fait. »), comme ImportCardModel.

Pour relire

  • src/hooks/useTransactions.ts : initTransactionsState, SET_PAGE_RESULT, SET_FILTER. Aucune action ne doit réarmer le repli.
  • src/utils/onboardingPath.ts : transactionsCardModel et CARD_PAGES.
  • src/components/transactions/TransactionTable.tsx : l'enveloppeur. Mise en page attendue inchangée : le div.relative du combobox devient l'enfant d'un div sans classe de taille.
  • src/i18n/config.ts, et les deux phrases dans fr.json et en.json.
  • À vérifier à la main (liste QA de #401) : profil neuf, « Étape suivante » depuis la carte Import, puis liste non catégorisée, période « Tout », carte et anneau sur la première catégorie ; classer une transaction, puis « Fait. » et « Saisir un premier budget » ; profil sans transaction à classer, puis toutes les transactions ; rendu de l'anneau sous WebKitGTK.
  • Limite connue, préexistante : fetchData envoie SET_LOADING puis SET_ERROR(null), qui remet isLoading à faux au début de chaque lecture. Après le repli, « Aucune transaction trouvée. » peut donc s'afficher le temps de la seconde lecture (quelques millisecondes), après « Chargement… ».

🤖 Generated with Claude Code

Resolves #399 Generated autonomously by /autopilot run of 2026-10-08 > PR empilée sur #404 (elle-même sur #403 puis #402) : elle ne montre que son propre diff. ## Ce qui change pour l'utilisateur - Quand l'étape suivante du parcours est « Classer une transaction à la main », la page Transactions affiche une carte de démarrage à la place du bandeau : « Étape 5 sur 6 · Classer une transaction à la main », une phrase qui dit quoi faire, et un contour qui pulse autour de la catégorie de la première transaction affichée. - Le lien de cette étape (bandeau, liste dépliée, « Étape suivante » de la carte Import) ouvre la page sur les transactions non catégorisées, toutes dates confondues : le filtre par défaut part du 1er janvier, et en janvier les relevés d'exemple tombaient l'année précédente. S'il n'en reste aucune à classer, la page montre toutes les transactions, une seule fois, et la phrase de la carte suit. - Phrase avec le filtre : « Seules les transactions non catégorisées sont affichées. Cliquez sur la catégorie de l'une d'elles… » ; sans le filtre : « Cliquez sur la catégorie d'une transaction… ». Le libellé du filtre vient de sa propre clé, en minuscules dans la phrase. - Classer une transaction coche l'étape en direct : la carte reste, annonce « Fait. » et propose « Saisir un premier budget », ou la fin du parcours en édition Gratuite. L'anneau disparaît. - Une visite ordinaire de la page garde les filtres par défaut (depuis le 1er janvier, toutes les catégories). ## Technique - `useTransactions(initialFilters?)` : `useReducer(transactionsReducer, initialFilters, initTransactionsState)`, filtres de départ posés sur les défauts et lus au montage seulement. Le reducer reste exporté ; nouvel état `guidedFallback`. - `GUIDED_ENTRY_FILTERS = { dateFrom: null, uncategorizedOnly: true }`, que la page passe quand `readOnboardingNavState(location.state).guided` est vrai (seule lecture de l'état de navigation, CWE-476). - Repli : sur `SET_PAGE_RESULT` avec `totalCount === 0` et `guidedFallback`, le reducer retire `uncategorizedOnly` (`dateFrom` nul gardé), remet la page à 1, laisse `isLoading` vrai et crée un nouvel objet `filters` : l'effet existant sur les filtres relit, après son attente de 300 ms. La première page, quel que soit son compte, et tout `SET_FILTER` désarment le repli : une seule fois, pas de boucle, jamais un filtre de l'utilisateur retiré dans son dos. Prédicat inchangé (`getTransactionPage`), pas de compteur `uncategorized`. - `transactionsCardModel(filters, checklist)` (`onboardingPath.ts`) : texte `uncategorized` (avec `labels: { filter: "transactions.filters.uncategorized" }`) ou `all`, cible `firstRowCategory` ; `{ text: null, done: true, target: null }` une fois l'étape cochée. `CARD_PAGES` reçoit `/transactions`. - `TransactionTable` reçoit `onboardingTarget?: boolean` : un enveloppeur `div.rounded-lg` autour du `CategoryCombobox` de chaque ligne, et seul celui de la première porte `data-onboarding-target="transaction-category"` et `data-onboarding-highlight`. `CategoryCombobox` ne change pas. - `TransactionsPage` rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne passe la cible que si la carte est affichée (`showsStepCard`), comme la page Import. - i18n : formateur `lowercase` enregistré dans `src/i18n/config.ts` ; les phrases FR et EN écrivent `{{filter, lowercase}}`. - CHANGELOG FR et EN (Ajouté) ; `docs/architecture.md` (ligne `useTransactions`, section Internationalisation). Aucun changement Rust, aucune migration. ## Preuves - `npm run build` vert (tsc + vite). - `npx vitest run` : 1814 → **1844** tests verts (101 → 103 fichiers). - `useTransactions.test.ts` (+8) : filtres de départ, visite ordinaire, repli unique, pas de boucle, filtre de l'utilisateur jamais retiré. - `onboardingPath.test.ts` (+8) : `CARD_PAGES`, `transactionsCardModel` (deux textes, « Fait. », étape passée), et les deux phrases résolues par la vraie instance i18n, en FR et en EN, contre le texte exact de l'issue (filtre en minuscules). - `TransactionTable.render.test.tsx` (nouveau, +4) : exactement un élément mis en évidence, l'enveloppeur de la catégorie de la première ligne, sans les boutons mot-clé et fractionnement. - `TransactionsPage.render.test.tsx` (nouveau, +10) : page rendue avec le vrai `useTransactions` ; entrée guidée sans borne de date, visite ordinaire, état de navigation malformé, carte avant le titre, une seule cible, aucune cible sans carte, « Fait. » puis l'étape Budget, fin en Gratuite. - `AppShell.render.test.tsx` : /transactions sort de la table « bandeau tant qu'aucune carte n'est livrée » et gagne son test de carte, comme /import dans #404. - Passe de mutations sur l'arbre commité, une à la fois, restaurée par `git checkout` ; 5 fichiers de test concernés, 108 tests verts avant chaque mutation : | # | Mutation | Rouges | |---|---|---| | M1 | la page ignore `guided` (aucun filtre de départ) | 2 | | M2 | pas de repli (`totalCount < 0`) | 1 | | M3 | la première page ne désarme pas le repli (boucle) | 3 | | M4 | une visite ordinaire arme le repli | 2 | | M5 | anneau sur chaque ligne (`index >= 0`) | 3 | | M6 | textes inversés (`!uncategorizedOnly`) | 5 | | M7 | `CARD_PAGES` sans `/transactions` | 2 | | M8 | « Fait. » lu sur l'étape Budget | 3 | **8/8 rouges**, arbre propre après chaque restauration. ## Décisions 12 décisions consignées dans le journal du run ; les principales : - [MEDIUM] `transactionsCardModel` prend aussi la liste (`checklist`), comme `importCardModel` : sans elle, pas de `done`, donc ni « Fait. » ni « Étape suivante » (critère d'acceptation). La signature de l'issue ne portait que `{ uncategorizedOnly }`. - [MEDIUM] Minuscule de `{{filter}}` par un formateur i18next `lowercase` (`config.ts`, hors de la liste de fichiers de l'issue) : la phrase de chaque langue décide de la casse, et le libellé reste interpolé depuis sa clé. Écartés : la minuscule dans `StepCard` (composant de #404) ; une clé recopiée en minuscules. - [MEDIUM] Repli armé quand les filtres de départ demandent « non catégorisées », désarmé par la première page et par tout `SET_FILTER` ; relecture par l'effet existant sur les filtres (300 ms), « Chargement… » entre-temps. Écarté : un jeton de relecture immédiate. - [MEDIUM] Entrée guidée lue au montage seulement : un lien guidé suivi depuis /transactions même (« Reprendre ») garde les filtres en cours, et la phrase de la carte suit le filtre réel. L'issue dit « démarre avec ». - [MEDIUM] Enveloppeur sur chaque ligne, attributs sur la première seulement : l'anneau qui apparaît ou disparaît ne remonte jamais le combobox. - [MEDIUM] Cible portée par le modèle (`target`, null après « Fait. »), comme `ImportCardModel`. ## Pour relire - `src/hooks/useTransactions.ts` : `initTransactionsState`, `SET_PAGE_RESULT`, `SET_FILTER`. Aucune action ne doit réarmer le repli. - `src/utils/onboardingPath.ts` : `transactionsCardModel` et `CARD_PAGES`. - `src/components/transactions/TransactionTable.tsx` : l'enveloppeur. Mise en page attendue inchangée : le `div.relative` du combobox devient l'enfant d'un `div` sans classe de taille. - `src/i18n/config.ts`, et les deux phrases dans `fr.json` et `en.json`. - À vérifier à la main (liste QA de #401) : profil neuf, « Étape suivante » depuis la carte Import, puis liste non catégorisée, période « Tout », carte et anneau sur la première catégorie ; classer une transaction, puis « Fait. » et « Saisir un premier budget » ; profil sans transaction à classer, puis toutes les transactions ; rendu de l'anneau sous WebKitGTK. - Limite connue, préexistante : `fetchData` envoie `SET_LOADING` puis `SET_ERROR(null)`, qui remet `isLoading` à faux au début de chaque lecture. Après le repli, « Aucune transaction trouvée. » peut donc s'afficher le temps de la seconde lecture (quelques millisecondes), après « Chargement… ». 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 2 commits 2026-10-08 12:59:53 +00:00
The link of the "categorize by hand" step carries `guided`, which the
Transactions page reads through `readOnboardingNavState` and turns into
start filters: `useTransactions(initialFilters?)` puts them over the
defaults through `initTransactionsState`, at mount only, so the card
consuming the navigation state changes nothing. The guided entry lists
the uncategorized transactions whatever their date (the default filter
starts on January 1st, and in January the sample statements fall in the
previous year). When its first page comes back empty, the reducer drops
"uncategorized only" once: a new filters object, so the existing filter
effect reads again, and the loading placeholder meanwhile. The first
page and any filter the user sets disarm the fallback, so it never loops
and never drops a filter behind the user's back.

`transactionsCardModel(filters, checklist)` says which sentence matches
the list, names the filter by its own label, points at the first row's
category, and says "Done." once the step is checked, which the card
needs to offer the Budget step or the end of the path. The label loses
its capital through an i18next `lowercase` formatter used by the
sentence itself, `{{filter, lowercase}}`.

TransactionTable wraps every row's category in the same element and
puts the ring on the first one only, so the ring coming or going never
remounts the combobox; CategoryCombobox is unchanged. CARD_PAGES gets
/transactions, and the page renders the card first and highlights its
target only while the card shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: changelog and architecture for the Transactions step card
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m5s
9a695a2504
Added: the getting-started card of the Transactions page and the guided
entry its step link opens. architecture.md gets the lines this change
touches: useTransactions' start filters and single fallback, and the
i18next `lowercase` formatter in the Internationalisation section.

Resolves #399

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-10-08 13:00:00 +00:00
Author
Owner

Auto-revue de l'autopilot (#399)

Verdict : APPROVE. C'est l'auto-revue du worker. L'issue passe en status:review, pas en status:approved : la revue indépendante reste à faire.

Relu sur le diff de cette PR seule (15 fichiers, tête 9a695a2).

Critères d'acceptation

  • Reducer de useTransactions (tests) : les filtres de départ sont appliqués par initTransactionsState. Le filtre guidé se replie sur totalCount === 0 une seule fois : la première page et tout SET_FILTER le désarment. Une visite ordinaire garde les filtres par défaut. 8 tests, et les mutations M2, M3 et M4 rougissent.

  • TransactionTable : avec onboardingTarget, un seul élément est mis en évidence, l'enveloppeur de la catégorie de la première ligne. Les boutons mot-clé et fractionnement restent hors de l'anneau. Le test de rendu le vérifie, et M5 rougit.

  • transactionsCardModel : les deux textes. Un test les résout aussi par la vraie instance i18n, en FR et en EN, contre le texte exact de l'issue, filtre en minuscules. M6 rougit.

  • Profil neuf, arrivée par « Étape suivante » : couvert au niveau de la page par TransactionsPage.render.test.tsx, avec le vrai useTransactions :

    • non catégorisées, sans borne de date ;
    • la carte avant le titre ;
    • une seule cible ;
    • « Fait. » puis « Saisir un premier budget » ;
    • la fin du parcours en édition Gratuite.

    Le cochage en direct repose sur la notification de updateTransactionCategory (ADR 0022, #402) : il reste à vérifier à la main.

  • FR, EN et CI : FR et EN, CHANGELOG FR et EN (Ajouté), npm run build vert, npx vitest run à 1844/1844. La CI check-frontend (run 503) est verte : build, 103 fichiers et 1844 tests, lus dans le journal du job.

Review caveats

  • L'état de navigation n'est lu que par readOnboardingNavState. Un test passe quatre états malformés, que la page lit comme une visite ordinaire.
  • Pas de boucle. Une seconde page vide garde le même objet filters, donc rien ne relit ; M3 (premier résultat qui ne désarme pas) rougit trois tests.

Conventions

  • La logique pure vit dans utils/ et l'état dans le reducer du hook ; les composants sont pilotés par props.
  • Toute chaîne visible passe par i18n, dans les deux langues.
  • Commentaires en anglais ; tests en environnement node, sans jsdom.
  • Aucun secret ni donnée personnelle ; pas de code mort ; CategoryCombobox est intact.

Constats (aucun bloquant)

  1. Écart de signature assumé : transactionsCardModel(filters, checklist) au lieu de ({ uncategorizedOnly }). Sans la liste, pas de done, donc ni « Fait. » ni « Étape suivante ». M8 (le « Fait. » lu sur l'étape Budget) rougit trois tests.
  2. Fichier hors liste : src/i18n/config.ts reçoit le formateur lowercase. C'est la phrase de chaque langue qui décide de la casse ({{filter, lowercase}}).
  3. Lien guidé suivi depuis /transactions même (« Reprendre » depuis la liste dépliée) : la page ne se remonte pas, donc les filtres en cours restent. La phrase de la carte suit le filtre réel. L'issue dit « démarre avec ».
  4. Limite préexistante : fetchData envoie SET_ERROR(null) au début de chaque lecture, ce qui remet isLoading à faux. Après le repli, « Aucune transaction trouvée. » peut donc s'afficher quelques millisecondes, après les 300 ms de « Chargement… ».
  5. À voir à la main (liste QA de #401) :
    • l'anneau autour du div.rounded-lg sous WebKitGTK ;
    • la mise en page de la colonne Catégorie, qui doit rester inchangée ;
    • le parcours profil neuf → « Étape suivante » depuis la carte Import → classement.

Passe de mutations (arbre commité)

8 mutations, 8 rouges : M1 = 2, M2 = 1, M3 = 3, M4 = 2, M5 = 3, M6 = 5, M7 = 2, M8 = 3. Le détail est dans la description de la PR.

## Auto-revue de l'autopilot (#399) **Verdict : APPROVE.** C'est l'auto-revue du worker. L'issue passe en `status:review`, pas en `status:approved` : la revue indépendante reste à faire. Relu sur le diff de cette PR seule (15 fichiers, tête `9a695a2`). ### Critères d'acceptation - [x] **Reducer de `useTransactions` (tests)** : les filtres de départ sont appliqués par `initTransactionsState`. Le filtre guidé se replie sur `totalCount === 0` une seule fois : la première page et tout `SET_FILTER` le désarment. Une visite ordinaire garde les filtres par défaut. 8 tests, et les mutations M2, M3 et M4 rougissent. - [x] **`TransactionTable`** : avec `onboardingTarget`, un seul élément est mis en évidence, l'enveloppeur de la catégorie de la première ligne. Les boutons mot-clé et fractionnement restent hors de l'anneau. Le test de rendu le vérifie, et M5 rougit. - [x] **`transactionsCardModel`** : les deux textes. Un test les résout aussi par la vraie instance i18n, en FR et en EN, contre le texte exact de l'issue, filtre en minuscules. M6 rougit. - [x] **Profil neuf, arrivée par « Étape suivante »** : couvert au niveau de la page par `TransactionsPage.render.test.tsx`, avec le vrai `useTransactions` : - non catégorisées, sans borne de date ; - la carte avant le titre ; - une seule cible ; - « Fait. » puis « Saisir un premier budget » ; - la fin du parcours en édition Gratuite. Le cochage en direct repose sur la notification de `updateTransactionCategory` (ADR 0022, #402) : il reste à vérifier à la main. - [x] **FR, EN et CI** : FR et EN, CHANGELOG FR et EN (Ajouté), `npm run build` vert, `npx vitest run` à 1844/1844. La CI `check-frontend` (run 503) est verte : build, 103 fichiers et 1844 tests, lus dans le journal du job. ### Review caveats - [x] L'état de navigation n'est lu que par `readOnboardingNavState`. Un test passe quatre états malformés, que la page lit comme une visite ordinaire. - [x] Pas de boucle. Une seconde page vide garde le même objet `filters`, donc rien ne relit ; M3 (premier résultat qui ne désarme pas) rougit trois tests. ### Conventions - La logique pure vit dans `utils/` et l'état dans le reducer du hook ; les composants sont pilotés par props. - Toute chaîne visible passe par i18n, dans les deux langues. - Commentaires en anglais ; tests en environnement node, sans jsdom. - Aucun secret ni donnée personnelle ; pas de code mort ; `CategoryCombobox` est intact. ### Constats (aucun bloquant) 1. **Écart de signature assumé** : `transactionsCardModel(filters, checklist)` au lieu de `({ uncategorizedOnly })`. Sans la liste, pas de `done`, donc ni « Fait. » ni « Étape suivante ». M8 (le « Fait. » lu sur l'étape Budget) rougit trois tests. 2. **Fichier hors liste** : `src/i18n/config.ts` reçoit le formateur `lowercase`. C'est la phrase de chaque langue qui décide de la casse (`{{filter, lowercase}}`). 3. **Lien guidé suivi depuis /transactions même** (« Reprendre » depuis la liste dépliée) : la page ne se remonte pas, donc les filtres en cours restent. La phrase de la carte suit le filtre réel. L'issue dit « démarre avec ». 4. **Limite préexistante** : `fetchData` envoie `SET_ERROR(null)` au début de chaque lecture, ce qui remet `isLoading` à faux. Après le repli, « Aucune transaction trouvée. » peut donc s'afficher quelques millisecondes, après les 300 ms de « Chargement… ». 5. **À voir à la main (liste QA de #401)** : - l'anneau autour du `div.rounded-lg` sous WebKitGTK ; - la mise en page de la colonne Catégorie, qui doit rester inchangée ; - le parcours profil neuf → « Étape suivante » depuis la carte Import → classement. ### Passe de mutations (arbre commité) 8 mutations, 8 rouges : M1 = 2, M2 = 1, M3 = 3, M4 = 2, M5 = 3, M6 = 5, M7 = 2, M8 = 3. Le détail est dans la description de la PR.
Author
Owner

Revue adversariale : PR #405 (#399)

Verdict : APPROVE

Résumé. L'entrée guidée, le repli unique et la carte de la page Transactions font ce que l'issue demande, sans bloquant. La suite et dix mutations du relecteur ont tourné sur une copie hors dépôt de la tête 9a695a2. Quatre mutations restent vertes : trois sur le reducer, qui casseraient le repli dans l'app sans qu'aucun test le voie, et une sur la page. Les deux tests qui les ferment tiennent en quelques lignes. Ce sont des épingles sur un code correct, donc des suggestions.

Vérifications

  • Suite rejouée sur une copie git archive de 9a695a2 (npm ci) : npm run build vert, npx vitest run à 103 fichiers et 1844 tests verts. Le journal du run 503 (check-frontend, checkout de 9a695a2) donne les mêmes chiffres.
  • Critères d'acceptation : tous couverts par les tests, sauf le cochage en direct. Il repose sur le notifyDataChanged() de updateTransactionCategory (ADR 0022, #402) et reste dans la liste QA de #401.
  • Review caveats : l'état de navigation n'est lu que par readOnboardingNavState. Le repli ne boucle pas : la première page et tout SET_FILTER le désarment, et seul initTransactionsState l'arme (aucune action du reducer ne le réarme).
  • Fermeture : la timeline de #399 porte un pull_ref closes venant de #405, et Resolves #399 figure dans le message de 9a695a2. Un merge ff local la ferme donc aussi.

Points relus sans constat

  • Une navigation vers la même route ne remonte pas la page (aucune key sur la route /transactions). Le navigate(pathname, { replace: true, state: null }) de la carte ne touche donc pas aux filtres de départ, comme l'annonce le commentaire de la page.
  • Le repli crée un nouvel objet filters. Le nettoyage de l'effet [state.filters] annule alors le minuteur posé au montage : aucune seconde lecture avec les filtres guidés ne part après le repli. Une réponse périmée reste écartée par fetchIdRef. Si la première lecture dépasse 300 ms, la réponse du minuteur devient la première page, et le repli s'applique à elle.
  • Mise en page : la racine de CategoryCombobox est un div.relative sans taille, et son input est en w-full. L'enveloppeur sans classe de taille ne change donc pas la largeur de la colonne. L'anneau (3 px, décalé de 2 px) tient dans le px-3 py-2 de la cellule, sous l'overflow-x-auto du tableau.
  • i18n : clés FR et EN à parité (aucun écart). {{filter, lowercase}} n'apparaît que dans ces deux phrases, et le formateur est présent après init : le test résout les deux phrases par la vraie instance, en FR et en EN.
  • L'affirmation « la catégorisation automatique ne le remplacera pas » tient : autoCategorizeTransactions ne lit que category_id IS NULL AND is_manually_categorized = 0.

Mutations du relecteur

Une à la fois, sur la copie, restaurées par git checkout, arbre propre après chacune. Les cinq fichiers de test de la PR, 108 tests verts avant chaque mutation.

# Mutation Rouges
R1 SET_ERROR désarme le repli 0
R2 SET_TOTAL_IN_DB désarme le repli 0
R3 SET_LOADING désarme le repli 0
R4 la carte suit l'état de navigation (guided) au lieu de state.filters 0
R5 le repli ne remet pas page à 1 0 (équivalente : la première page est toujours la page 1)
R6 le repli ne pose pas isLoading 1
R7 anneau sans la carte (cardModel.target au lieu de target) 1
R8 formateur lowercase sans mise en minuscules 2
R9 le repli remet tous les filtres par défaut (la borne du 1er janvier revient) 1
R10 tout filtre de départ arme le repli 1

Suggestions (non bloquantes)

  1. Rejouer la séquence réelle du hook dans les tests du reducer (useTransactions.test.ts). Les tests envoient SET_PAGE_RESULT directement après initTransactionsState. Or fetchData envoie SET_LOADING(true) puis SET_ERROR(null) avant chaque SET_PAGE_RESULT, et l'effet du montage envoie SET_CATEGORIES, SET_SOURCES et SET_TOTAL_IN_DB, avant ou après la première page selon l'ordre d'arrivée des requêtes. Sous R1 ou R3, le repli ne se déclencherait jamais dans l'app ; sous R2, selon cet ordre. Les 108 tests restent verts dans les trois cas. Un test qui réduit [SET_LOADING(true), SET_ERROR(null), SET_CATEGORIES, SET_SOURCES, SET_TOTAL_IN_DB, SET_PAGE_RESULT(0)] depuis initTransactionsState(GUIDED_ENTRY_FILTERS) et attend uncategorizedOnly: false, dateFrom: null et isLoading: true passe sur 9a695a2 et rougit sous R1, R2 et R3 (mesuré sur la copie).
  2. Épingler au niveau de la page que la carte suit le filtre de la liste, pas l'état de navigation (TransactionsPage.render.test.tsx). Le harnais remplace déjà rows ; s'il remplace aussi filters, deux cas suffisent : une entrée guidée après le repli (uncategorizedOnly: false) donne la phrase all, une visite ordinaire avec « Non catégorisées » choisi à la main donne la phrase uncategorized. Les deux passent sur 9a695a2 et rougissent sous R4 (mesuré). Sans eux, une page qui calculerait la phrase depuis guided afficherait « Seules les transactions non catégorisées sont affichées » au-dessus de toutes les transactions après le repli, et la phrase basculerait dès que la carte consomme l'état de navigation.
  3. Cas limite hors du périmètre de #399, pour #384 ou la liste QA de #401 : un profil sans transaction dont l'étape suivante est « Classer une transaction à la main », parce que les trois étapes d'import ont été passées par « Passer cette étape ». La carte s'affiche au-dessus de l'état vide « importez d'abord » et demande de cliquer sur la catégorie d'une transaction qui n'existe pas. Le isLoading: true du repli y affiche aussi la barre de filtres et « Chargement… » environ 300 ms à la place de l'état vide.

Les suggestions 1 et 2 peuvent s'appliquer en tête de pile (#407), comme pour les suites de licence, sans toucher aux têtes déjà revues. Merge recommandé.

Actions faites

  • Copie hors dépôt /tmp/pr405-review (git archive de 9a695a2, puis npm ci) : build, suite complète, 10 mutations, et 2 fichiers de test jetables (séquence du reducer, phrase de la carte) pour mesurer les suggestions 1 et 2.
  • Copie et fichiers /tmp/pr405-* supprimés en fin de revue. Aucun fichier du dépôt modifié.
## Revue adversariale : PR #405 (#399) **Verdict : APPROVE** **Résumé.** L'entrée guidée, le repli unique et la carte de la page Transactions font ce que l'issue demande, sans bloquant. La suite et dix mutations du relecteur ont tourné sur une copie hors dépôt de la tête `9a695a2`. Quatre mutations restent vertes : trois sur le reducer, qui casseraient le repli dans l'app sans qu'aucun test le voie, et une sur la page. Les deux tests qui les ferment tiennent en quelques lignes. Ce sont des épingles sur un code correct, donc des suggestions. ### Vérifications - **Suite rejouée** sur une copie `git archive` de `9a695a2` (`npm ci`) : `npm run build` vert, `npx vitest run` à **103 fichiers et 1844 tests verts**. Le journal du run 503 (`check-frontend`, checkout de `9a695a2`) donne les mêmes chiffres. - **Critères d'acceptation** : tous couverts par les tests, sauf le cochage en direct. Il repose sur le `notifyDataChanged()` de `updateTransactionCategory` (ADR 0022, #402) et reste dans la liste QA de #401. - **Review caveats** : l'état de navigation n'est lu que par `readOnboardingNavState`. Le repli ne boucle pas : la première page et tout `SET_FILTER` le désarment, et seul `initTransactionsState` l'arme (aucune action du reducer ne le réarme). - **Fermeture** : la timeline de #399 porte un `pull_ref` `closes` venant de #405, et `Resolves #399` figure dans le message de `9a695a2`. Un merge ff local la ferme donc aussi. ### Points relus sans constat - Une navigation vers la même route ne remonte pas la page (aucune `key` sur la route `/transactions`). Le `navigate(pathname, { replace: true, state: null })` de la carte ne touche donc pas aux filtres de départ, comme l'annonce le commentaire de la page. - Le repli crée un nouvel objet `filters`. Le nettoyage de l'effet `[state.filters]` annule alors le minuteur posé au montage : aucune seconde lecture avec les filtres guidés ne part après le repli. Une réponse périmée reste écartée par `fetchIdRef`. Si la première lecture dépasse 300 ms, la réponse du minuteur devient la première page, et le repli s'applique à elle. - Mise en page : la racine de `CategoryCombobox` est un `div.relative` sans taille, et son `input` est en `w-full`. L'enveloppeur sans classe de taille ne change donc pas la largeur de la colonne. L'anneau (3 px, décalé de 2 px) tient dans le `px-3 py-2` de la cellule, sous l'`overflow-x-auto` du tableau. - i18n : clés FR et EN à parité (aucun écart). `{{filter, lowercase}}` n'apparaît que dans ces deux phrases, et le formateur est présent après `init` : le test résout les deux phrases par la vraie instance, en FR et en EN. - L'affirmation « la catégorisation automatique ne le remplacera pas » tient : `autoCategorizeTransactions` ne lit que `category_id IS NULL AND is_manually_categorized = 0`. ### Mutations du relecteur Une à la fois, sur la copie, restaurées par `git checkout`, arbre propre après chacune. Les cinq fichiers de test de la PR, 108 tests verts avant chaque mutation. | # | Mutation | Rouges | |---|---|---| | R1 | `SET_ERROR` désarme le repli | **0** | | R2 | `SET_TOTAL_IN_DB` désarme le repli | **0** | | R3 | `SET_LOADING` désarme le repli | **0** | | R4 | la carte suit l'état de navigation (`guided`) au lieu de `state.filters` | **0** | | R5 | le repli ne remet pas `page` à 1 | 0 (équivalente : la première page est toujours la page 1) | | R6 | le repli ne pose pas `isLoading` | 1 | | R7 | anneau sans la carte (`cardModel.target` au lieu de `target`) | 1 | | R8 | formateur `lowercase` sans mise en minuscules | 2 | | R9 | le repli remet tous les filtres par défaut (la borne du 1er janvier revient) | 1 | | R10 | tout filtre de départ arme le repli | 1 | ### Suggestions (non bloquantes) 1. **Rejouer la séquence réelle du hook dans les tests du reducer** (`useTransactions.test.ts`). Les tests envoient `SET_PAGE_RESULT` directement après `initTransactionsState`. Or `fetchData` envoie `SET_LOADING(true)` puis `SET_ERROR(null)` avant chaque `SET_PAGE_RESULT`, et l'effet du montage envoie `SET_CATEGORIES`, `SET_SOURCES` et `SET_TOTAL_IN_DB`, avant ou après la première page selon l'ordre d'arrivée des requêtes. Sous R1 ou R3, le repli ne se déclencherait jamais dans l'app ; sous R2, selon cet ordre. Les 108 tests restent verts dans les trois cas. Un test qui réduit `[SET_LOADING(true), SET_ERROR(null), SET_CATEGORIES, SET_SOURCES, SET_TOTAL_IN_DB, SET_PAGE_RESULT(0)]` depuis `initTransactionsState(GUIDED_ENTRY_FILTERS)` et attend `uncategorizedOnly: false`, `dateFrom: null` et `isLoading: true` passe sur `9a695a2` et rougit sous R1, R2 et R3 (mesuré sur la copie). 2. **Épingler au niveau de la page que la carte suit le filtre de la liste, pas l'état de navigation** (`TransactionsPage.render.test.tsx`). Le harnais remplace déjà `rows` ; s'il remplace aussi `filters`, deux cas suffisent : une entrée guidée après le repli (`uncategorizedOnly: false`) donne la phrase `all`, une visite ordinaire avec « Non catégorisées » choisi à la main donne la phrase `uncategorized`. Les deux passent sur `9a695a2` et rougissent sous R4 (mesuré). Sans eux, une page qui calculerait la phrase depuis `guided` afficherait « Seules les transactions non catégorisées sont affichées » au-dessus de toutes les transactions après le repli, et la phrase basculerait dès que la carte consomme l'état de navigation. 3. **Cas limite hors du périmètre de #399, pour #384 ou la liste QA de #401** : un profil sans transaction dont l'étape suivante est « Classer une transaction à la main », parce que les trois étapes d'import ont été passées par « Passer cette étape ». La carte s'affiche au-dessus de l'état vide « importez d'abord » et demande de cliquer sur la catégorie d'une transaction qui n'existe pas. Le `isLoading: true` du repli y affiche aussi la barre de filtres et « Chargement… » environ 300 ms à la place de l'état vide. Les suggestions 1 et 2 peuvent s'appliquer en tête de pile (#407), comme pour les suites de licence, sans toucher aux têtes déjà revues. Merge recommandé. ### Actions faites - Copie hors dépôt `/tmp/pr405-review` (`git archive` de `9a695a2`, puis `npm ci`) : build, suite complète, 10 mutations, et 2 fichiers de test jetables (séquence du reducer, phrase de la carte) pour mesurer les suggestions 1 et 2. - Copie et fichiers `/tmp/pr405-*` supprimés en fin de revue. Aucun fichier du dépôt modifié.
maximus changed target branch from issue-398-onboarding-step-card-import to main 2026-10-09 01:10:41 +00:00
maximus merged commit 9a695a2504 into main 2026-10-09 01:10:48 +00:00
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#405
No description provided.