feat(onboarding): shared getting-started state, live re-read and data-change channel (ADR 0022) #402

Merged
maximus merged 4 commits from issue-396-onboarding-shared-state into main 2026-10-09 01:10:05 +00:00
Owner

Resolves #396

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

Ce qui change pour l'utilisateur

  • Un profil qui avait déjà des transactions avant cette version ne voit plus la liste de démarrage : à sa première ouverture sous cette version, elle est masquée comme après « Masquer » (règle D9), et « Afficher la liste de démarrage » (Paramètres → Utilisateurs) la fait revenir. Un profil vide ou neuf la garde.
  • Rien d'autre de visible : le tableau de bord garde sa barre compacte et sa carte d'accueil. La liste n'est plus lue au montage du tableau de bord mais par un fournisseur partagé, relu à chaque changement de page et après chaque écriture qui change un compteur. Revenir sur le tableau de bord n'affiche donc plus d'état de chargement après la première ouverture.

Technique

  • src/services/dataEvents.ts (ADR 0022) : notifyDataChanged() et subscribeDataChanged(), sans React, sur le modèle de logService.subscribe. Un abonné qui lève est écrit au Journal et n'empêche pas les autres ; notifyDataChanged ne lève jamais.
  • Notifications après chaque écriture réussie de la liste de l'issue (setImportFolder, createSource, deleteSource, createImportedFile, updateFileStatus, les deux suppressions d'import, insertBatch, updateTransactionCategory, applyKeywordWithReassignment, les quatre écritures du budget, runRestore), plus une dans le finally d'executeImport. Les écritures faites dans withTransaction notifient après son retour (COMMIT fait, verrou relâché). applyTemplate notifie une fois pour tout le modèle, par un helper privé writeBudgetEntry.
  • onboardingService : getOnboardingSnapshot remplace getOnboardingCounters et ne fait que lire (étapes passées : une préférence onboarding_skipped_<clé> = "1" par étape, lues en une requête et filtrées sur les six clés) ; initializeGuidedPath applique D9 dans withTransaction (BEGIN, relecture du drapeau, masquage puis drapeau, COMMIT, ROLLBACK sur échec) et écrit une ligne au Journal quand il masque ; skipStep et resumeStep sont idempotents. Aucune des écritures du parcours ne notifie.
  • computeChecklist : option skipped ; chaque ligne porte done, skipped et locked ; le compteur compte les lignes déverrouillées faites ou passées, une fois chacune.
  • src/utils/onboardingPath.ts (pur) : nextStep, CARD_PAGES (vide), stepsOfPage, latchCardPage, bannerMode, readOnboardingNavState.
  • useOnboardingChecklist garde le reducer : chargements numérotés (réponse périmée ignorée), HIDE / SHOW / SKIP / RESUME prioritaires sur le snapshot d'un chargement parti avant eux, rafraîchissement qui garde ready et les compteurs, échec après succès qui garde l'état. createOnboardingLoader attend D9 une fois (promesse partagée) et n'écrit au Journal qu'au passage en échec ; followDataChanges relit 150 ms après une rafale ; relecture à chaque pathname ; deriveOnboardingView (pur) calcule l'étape suivante, le verrou de carte et le mode.
  • src/contexts/OnboardingContext.tsx : OnboardingProvider, monté dans AppShell autour de <Outlet/>, et useOnboarding().
  • DashboardPage lit useOnboarding() ; la barre compacte reste.
  • Docs : ADR 0022, sa ligne dans la table des ADR et les lignes de docs/architecture.md que ce changement rendait fausses ; CHANGELOG FR et EN (Modifié / Changed).
  • Aucune migration, aucun changement Rust (pas de check-rust attendu).

Preuves

  • npm run build : vert (tsc + vite).
  • npx vitest run : 1520 → 1680 tests (80 → 89 fichiers), tous verts.
  • CI check-frontend : run 499 vert sur 68b90f5 ; le run de la tête 21c46b6 (test de câblage ajouté après l'auto-revue) est confirmé dans le commentaire de revue.
  • Passe de mutations sur l'arbre commité (M1 à M8 sur 68b90f5, M9 à M12 sur 21c46b6), une mutation à la fois, git diff non vide vérifié, fichier restauré par git checkout ; suites concernées vertes avant la passe.
# Mutation Suites lancées Rouges
M1 garde « réponse périmée » retirée de LOAD_DONE useOnboardingChecklist 2/44
M2 HIDE / SHOW ne priment plus sur un snapshot plus ancien useOnboardingChecklist 3/44
M3 le verrou ne tient plus quand l'étape se coche page ouverte onboardingPath + useOnboardingChecklist 3/81
M4 SKIP ne relâche plus le verrou useOnboardingChecklist 4/44
M5 D9 écrit le drapeau avant le masquage onboardingService 2/27
M6 D9 sans ROLLBACK onboardingService 2/27
M7 updateTransactionCategory notifie avant que son écriture aboutisse transactionService 2/10
M8 executeImport sans notification finale useImportWizard.executeImport 3/4
M9 la relecture ne dépend plus de la route useOnboardingChecklist.wiring 1/7
M10 le hook ne suit plus le canal de données useOnboardingChecklist.wiring 1/7
M11 un verrou de carte changé n'est plus rangé useOnboardingChecklist.wiring 1/7
M12 hide met l'état à jour avant son écriture useOnboardingChecklist.wiring 2/7

12/12 mutations rouges.

Décisions

  • [HIGH] Notification d'une écriture dans une transaction : applyKeywordWithReassignment et runRestore notifient après le retour de withTransaction (COMMIT fait, verrou de db.ts relâché), jamais dans le rappel : un abonné qui lirait la base pendant inTransaction contournerait le verrou.
  • [HIGH] createSource notifie avant la relecture : createSource notifie juste après son UPSERT, avant la relecture getSourceByName qui suit un conflit de nom et ne fait que lire.
  • [MEDIUM] applyTemplate : une notification pour tout le modèle : applyTemplate passe par un helper privé writeBudgetEntry (l'UPSERT d'upsertBudgetEntry sans notification) et notifie une fois après la dernière entrée ; une entrée en échec ne notifie rien. Écarté : garder l'appel au upsertBudgetEntry public (N + 1 notifications, et un échec à l'entrée k > 1 aurait déjà notifié, contre « pas après un échec »).
  • [MEDIUM] Écritures en plusieurs requêtes qui échouent à mi-chemin : deleteImportWithTransactions, deleteAllImportsWithTransactions, insertBatch et upsertBudgetEntriesForYear notifient une fois après leur dernière requête ; un échec à mi-chemin ne notifie rien, même si des lignes ont changé (consigné dans l'ADR 0022). La notification finale d'executeImport et la relecture au changement de route rattrapent. Écarté : notifier dans un finally (contraire au critère « pas après un échec »).
  • [MEDIUM] Test d'executeImport sans renderer : useImportWizard.executeImport.test.ts remplace les trois hooks React du module (useReducer, useCallback, useEffect) par vi.mock("react"), appelle le hook comme une fonction sur un état préparé et moque les services : le vrai executeImport est exercé (succès, échec d'insertion, échec avant insertion, rien à importer). Écarté : extraire le corps de l'import dans une fonction exportée (refactor large de l'assistant) ; tester seulement un helper (le site d'appel resterait sans garde, la mutation M8 le prouve rouge).
  • [HIGH] skipped optionnel dans computeChecklist : L'option skipped de computeChecklist est optionnelle (aucune par défaut) : les appelants et tests existants restent inchangés (plan : « adaptés seulement aux nouvelles propriétés optionnelles »). ChecklistItem.skipped est requis.
  • [MEDIUM] Statut error seulement avant un premier succès : LOAD_START garde ready après un premier succès, et repasse en loading depuis error (comportement et test existants conservés). Écarté : garder error pendant une nouvelle tentative (aurait changé un comportement testé sans demande).
  • [MEDIUM] Champ error retiré, avertissement du tableau de bord retiré : Le reducer n'a plus de champ error et DashboardPage n'écrit plus son logWarn : le chargeur du fournisseur écrit une seule ligne logError au début d'une série d'échecs. Le logWarn du tableau de bord aurait ajouté une ligne à chaque visite pendant la panne, contre « une seule ligne au Journal au passage en échec ». Écarté : exposer error dans le contexte (absent de la liste de useOnboarding() de l'issue).
  • [MEDIUM] Échec de l'initialisation D9 : Un initializeGuidedPath qui échoue est écrit au Journal et le premier chargement continue : le parcours s'affiche selon la préférence stockée (comportement de la v0.17.0) et le drapeau, non posé, fait réessayer à l'ouverture suivante. Écarté : faire échouer le premier chargement (un incident passager masquerait le parcours d'un nouvel utilisateur).
  • [HIGH] Drapeau D9 et valeur rendue : onboarding_guided_initialized compte comme posé seulement pour la valeur "1" (même règle que les autres préférences du parcours) ; initializeGuidedPath rend { hidden: true } seulement quand cet appel a masqué le parcours. Le drapeau déjà posé donne BEGIN puis COMMIT, sans écriture.
  • [HIGH] Promesse D9 partagée par instance de fournisseur : La promesse d'initialisation vit dans le chargeur, créé par l'initialiseur de useState du fournisseur (même motif que revocationCheckLauncher de LicenseContext), pas au niveau du module : le double montage de StrictMode la partage, et un changement de profil (remontage d'AppShell) initialise le nouveau profil.
  • [MEDIUM] Où vit le verrou de carte : Le verrou est calculé pendant le rendu par latchCardPage (via deriveOnboardingView, pur et testé) et rangé dans le reducer par un dispatch en phase de rendu (SET_CARD_PAGE, idempotent, une seule passe) ; SKIP le remet à null dans le reducer, et le rendu suivant le repose seulement si la nouvelle étape suivante est sur la même page. Écartés : un effet (une image de bandeau sur une page à carte) ; un argument « passé » ajouté à latchCardPage (absent de la signature de l'issue).
  • [MEDIUM] Mode pendant le chargement de la licence : bannerMode rend none (et non complete) tant que budgetReady est faux et que la liste calculée Budget verrouillé se dit complète : la fin peut être à une étape Budget près. Il rend banner quand des étapes restent (le compteur du bandeau attendra budgetReady, issue 2). Écarté : banner sans étape suivante.
  • [HIGH] cardPages injectable : latchCardPage et deriveOnboardingView acceptent un cardPages optionnel (par défaut CARD_PAGES) : le verrou est testé sur toute sa table pendant que CARD_PAGES est vide.
  • [HIGH] Attente de 150 ms : Attente glissante : chaque événement relance les 150 ms (followDataChanges), le nettoyage désabonne et annule une relecture en attente.
  • [HIGH] Abonné qui lève : notifyDataChanged écrit un abonné qui lève au Journal (logError) et appelle les suivants ; il ne lève jamais (habitude de journalisation du dépôt).
  • [HIGH] Documentation : Outre la ligne de la table des ADR, docs/architecture.md reçoit les lignes que ce changement rendait fausses (onboardingService, useOnboardingChecklist, nouveau service dataEvents, dossier contexts/, comptes 25 → 26 et 19 → 20). La réécriture de la section « Onboarding » et le guide utilisateur restent à l'issue 6.
  • [HIGH] Paramètres → Utilisateurs inchangé : UsersSettingsPage appelle encore showChecklist() directement dans cette issue (le passage par le contexte est l'objet de l'issue 2) ; la relecture du fournisseur au changement de route reprend la préférence.
  • [HIGH] Test du câblage du hook : Relevé en auto-revue : renderToStaticMarkup ne lance aucun effet, donc la relecture par route (pathname dans les dépendances), l'abonnement au canal et son nettoyage, le rangement du verrou et l'ordre « écrire, puis mettre l'état à jour » des actions n'étaient gardés par aucun test. useOnboardingChecklist.wiring.test.ts les épingle avec la même technique que le test d'executeImport (hooks React remplacés, effets capturés et lancés à la main) ; les mutations M9 à M12 le prouvent rouge.

Pour relire

  • Dispatch en phase de rendu (useOnboardingChecklist.ts, SET_CARD_PAGE) : le verrou de carte est dérivé pendant le rendu et rangé dans le reducer du même composant, ce que React applique avant le commit. latchCardPage est idempotent (testé), donc une seule passe. C'est le point le plus inhabituel du diff.
  • vi.mock("react") dans useImportWizard.executeImport.test.ts et useOnboardingChecklist.wiring.test.ts : technique nouvelle dans ce dépôt, limitée à ces deux fichiers, pour exercer le vrai code des hooks sans renderer. Elle prouve ce que le hook demande à React (dépendances, abonnement, nettoyage, dispatch), pas la sémantique de React elle-même (double montage de StrictMode, application d'une mise à jour en phase de rendu).
  • Précédence des actions locales : hiddenSetAt / skippedSetAt gardent le numéro du dernier chargement lancé au moment de l'action ; un LOAD_DONE de numéro inférieur ou égal garde la valeur locale pour ce champ seulement.
  • bannerMode rend none, et non complete, tant que budgetReady est faux et que la liste calculée Budget verrouillé se dit complète.
  • DashboardPage n'écrit plus son logWarn d'échec : le fournisseur écrit une ligne au début d'une série d'échecs.
  • À vérifier à la main (liste QA de l'issue 6) : la règle D9 sur un profil réel qui a des transactions, et une étape cochée en direct, page ouverte, une fois le bandeau livré (issue 2).
  • UsersSettingsPage écrit encore la préférence elle-même (l'issue 2 la fait passer par le contexte) ; la relecture au changement de route la reprend.

🤖 Generated with Claude Code

Resolves #396 Generated autonomously by /autopilot run of 2026-10-08 ## Ce qui change pour l'utilisateur - Un profil qui avait déjà des transactions avant cette version ne voit plus la liste de démarrage : à sa première ouverture sous cette version, elle est masquée comme après « Masquer » (règle D9), et « Afficher la liste de démarrage » (Paramètres → Utilisateurs) la fait revenir. Un profil vide ou neuf la garde. - Rien d'autre de visible : le tableau de bord garde sa barre compacte et sa carte d'accueil. La liste n'est plus lue au montage du tableau de bord mais par un fournisseur partagé, relu à chaque changement de page et après chaque écriture qui change un compteur. Revenir sur le tableau de bord n'affiche donc plus d'état de chargement après la première ouverture. ## Technique - `src/services/dataEvents.ts` ([ADR 0022](docs/adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` et `subscribeDataChanged()`, sans React, sur le modèle de `logService.subscribe`. Un abonné qui lève est écrit au Journal et n'empêche pas les autres ; `notifyDataChanged` ne lève jamais. - Notifications après chaque écriture réussie de la liste de l'issue (`setImportFolder`, `createSource`, `deleteSource`, `createImportedFile`, `updateFileStatus`, les deux suppressions d'import, `insertBatch`, `updateTransactionCategory`, `applyKeywordWithReassignment`, les quatre écritures du budget, `runRestore`), plus une dans le `finally` d'`executeImport`. Les écritures faites dans `withTransaction` notifient après son retour (COMMIT fait, verrou relâché). `applyTemplate` notifie une fois pour tout le modèle, par un helper privé `writeBudgetEntry`. - `onboardingService` : `getOnboardingSnapshot` remplace `getOnboardingCounters` et ne fait que lire (étapes passées : une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les six clés) ; `initializeGuidedPath` applique D9 dans `withTransaction` (BEGIN, relecture du drapeau, masquage puis drapeau, COMMIT, ROLLBACK sur échec) et écrit une ligne au Journal quand il masque ; `skipStep` et `resumeStep` sont idempotents. Aucune des écritures du parcours ne notifie. - `computeChecklist` : option `skipped` ; chaque ligne porte `done`, `skipped` et `locked` ; le compteur compte les lignes déverrouillées faites ou passées, une fois chacune. - `src/utils/onboardingPath.ts` (pur) : `nextStep`, `CARD_PAGES` (vide), `stepsOfPage`, `latchCardPage`, `bannerMode`, `readOnboardingNavState`. - `useOnboardingChecklist` garde le reducer : chargements numérotés (réponse périmée ignorée), `HIDE` / `SHOW` / `SKIP` / `RESUME` prioritaires sur le snapshot d'un chargement parti avant eux, rafraîchissement qui garde `ready` et les compteurs, échec après succès qui garde l'état. `createOnboardingLoader` attend D9 une fois (promesse partagée) et n'écrit au Journal qu'au passage en échec ; `followDataChanges` relit 150 ms après une rafale ; relecture à chaque `pathname` ; `deriveOnboardingView` (pur) calcule l'étape suivante, le verrou de carte et le mode. - `src/contexts/OnboardingContext.tsx` : `OnboardingProvider`, monté dans `AppShell` autour de `<Outlet/>`, et `useOnboarding()`. - `DashboardPage` lit `useOnboarding()` ; la barre compacte reste. - Docs : ADR 0022, sa ligne dans la table des ADR et les lignes de `docs/architecture.md` que ce changement rendait fausses ; CHANGELOG FR et EN (Modifié / Changed). - Aucune migration, aucun changement Rust (pas de `check-rust` attendu). ## Preuves - `npm run build` : vert (tsc + vite). - `npx vitest run` : **1520 → 1680** tests (80 → 89 fichiers), tous verts. - CI `check-frontend` : run 499 vert sur `68b90f5` ; le run de la tête `21c46b6` (test de câblage ajouté après l'auto-revue) est confirmé dans le commentaire de revue. - Passe de mutations sur l'arbre commité (M1 à M8 sur `68b90f5`, M9 à M12 sur `21c46b6`), une mutation à la fois, `git diff` non vide vérifié, fichier restauré par `git checkout` ; suites concernées vertes avant la passe. | # | Mutation | Suites lancées | Rouges | |---|---|---|---| | M1 | garde « réponse périmée » retirée de `LOAD_DONE` | useOnboardingChecklist | 2/44 | | M2 | `HIDE` / `SHOW` ne priment plus sur un snapshot plus ancien | useOnboardingChecklist | 3/44 | | M3 | le verrou ne tient plus quand l'étape se coche page ouverte | onboardingPath + useOnboardingChecklist | 3/81 | | M4 | `SKIP` ne relâche plus le verrou | useOnboardingChecklist | 4/44 | | M5 | D9 écrit le drapeau avant le masquage | onboardingService | 2/27 | | M6 | D9 sans `ROLLBACK` | onboardingService | 2/27 | | M7 | `updateTransactionCategory` notifie avant que son écriture aboutisse | transactionService | 2/10 | | M8 | `executeImport` sans notification finale | useImportWizard.executeImport | 3/4 | | M9 | la relecture ne dépend plus de la route | useOnboardingChecklist.wiring | 1/7 | | M10 | le hook ne suit plus le canal de données | useOnboardingChecklist.wiring | 1/7 | | M11 | un verrou de carte changé n'est plus rangé | useOnboardingChecklist.wiring | 1/7 | | M12 | `hide` met l'état à jour avant son écriture | useOnboardingChecklist.wiring | 2/7 | **12/12 mutations rouges.** ## Décisions - **[HIGH] Notification d'une écriture dans une transaction** : `applyKeywordWithReassignment` et `runRestore` notifient après le retour de `withTransaction` (COMMIT fait, verrou de `db.ts` relâché), jamais dans le rappel : un abonné qui lirait la base pendant `inTransaction` contournerait le verrou. - **[HIGH] createSource notifie avant la relecture** : `createSource` notifie juste après son UPSERT, avant la relecture `getSourceByName` qui suit un conflit de nom et ne fait que lire. - **[MEDIUM] applyTemplate : une notification pour tout le modèle** : `applyTemplate` passe par un helper privé `writeBudgetEntry` (l'UPSERT d'`upsertBudgetEntry` sans notification) et notifie une fois après la dernière entrée ; une entrée en échec ne notifie rien. Écarté : garder l'appel au `upsertBudgetEntry` public (N + 1 notifications, et un échec à l'entrée k > 1 aurait déjà notifié, contre « pas après un échec »). - **[MEDIUM] Écritures en plusieurs requêtes qui échouent à mi-chemin** : `deleteImportWithTransactions`, `deleteAllImportsWithTransactions`, `insertBatch` et `upsertBudgetEntriesForYear` notifient une fois après leur dernière requête ; un échec à mi-chemin ne notifie rien, même si des lignes ont changé (consigné dans l'ADR 0022). La notification finale d'`executeImport` et la relecture au changement de route rattrapent. Écarté : notifier dans un `finally` (contraire au critère « pas après un échec »). - **[MEDIUM] Test d'executeImport sans renderer** : `useImportWizard.executeImport.test.ts` remplace les trois hooks React du module (`useReducer`, `useCallback`, `useEffect`) par `vi.mock("react")`, appelle le hook comme une fonction sur un état préparé et moque les services : le vrai `executeImport` est exercé (succès, échec d'insertion, échec avant insertion, rien à importer). Écarté : extraire le corps de l'import dans une fonction exportée (refactor large de l'assistant) ; tester seulement un helper (le site d'appel resterait sans garde, la mutation M8 le prouve rouge). - **[HIGH] `skipped` optionnel dans computeChecklist** : L'option `skipped` de `computeChecklist` est optionnelle (aucune par défaut) : les appelants et tests existants restent inchangés (plan : « adaptés seulement aux nouvelles propriétés optionnelles »). `ChecklistItem.skipped` est requis. - **[MEDIUM] Statut `error` seulement avant un premier succès** : `LOAD_START` garde `ready` après un premier succès, et repasse en `loading` depuis `error` (comportement et test existants conservés). Écarté : garder `error` pendant une nouvelle tentative (aurait changé un comportement testé sans demande). - **[MEDIUM] Champ `error` retiré, avertissement du tableau de bord retiré** : Le reducer n'a plus de champ `error` et `DashboardPage` n'écrit plus son `logWarn` : le chargeur du fournisseur écrit une seule ligne `logError` au début d'une série d'échecs. Le `logWarn` du tableau de bord aurait ajouté une ligne à chaque visite pendant la panne, contre « une seule ligne au Journal au passage en échec ». Écarté : exposer `error` dans le contexte (absent de la liste de `useOnboarding()` de l'issue). - **[MEDIUM] Échec de l'initialisation D9** : Un `initializeGuidedPath` qui échoue est écrit au Journal et le premier chargement continue : le parcours s'affiche selon la préférence stockée (comportement de la v0.17.0) et le drapeau, non posé, fait réessayer à l'ouverture suivante. Écarté : faire échouer le premier chargement (un incident passager masquerait le parcours d'un nouvel utilisateur). - **[HIGH] Drapeau D9 et valeur rendue** : `onboarding_guided_initialized` compte comme posé seulement pour la valeur "1" (même règle que les autres préférences du parcours) ; `initializeGuidedPath` rend `{ hidden: true }` seulement quand cet appel a masqué le parcours. Le drapeau déjà posé donne BEGIN puis COMMIT, sans écriture. - **[HIGH] Promesse D9 partagée par instance de fournisseur** : La promesse d'initialisation vit dans le chargeur, créé par l'initialiseur de `useState` du fournisseur (même motif que `revocationCheckLauncher` de `LicenseContext`), pas au niveau du module : le double montage de StrictMode la partage, et un changement de profil (remontage d'AppShell) initialise le nouveau profil. - **[MEDIUM] Où vit le verrou de carte** : Le verrou est calculé pendant le rendu par `latchCardPage` (via `deriveOnboardingView`, pur et testé) et rangé dans le reducer par un dispatch en phase de rendu (`SET_CARD_PAGE`, idempotent, une seule passe) ; `SKIP` le remet à null dans le reducer, et le rendu suivant le repose seulement si la nouvelle étape suivante est sur la même page. Écartés : un effet (une image de bandeau sur une page à carte) ; un argument « passé » ajouté à `latchCardPage` (absent de la signature de l'issue). - **[MEDIUM] Mode pendant le chargement de la licence** : `bannerMode` rend `none` (et non `complete`) tant que `budgetReady` est faux et que la liste calculée Budget verrouillé se dit complète : la fin peut être à une étape Budget près. Il rend `banner` quand des étapes restent (le compteur du bandeau attendra `budgetReady`, issue 2). Écarté : `banner` sans étape suivante. - **[HIGH] `cardPages` injectable** : `latchCardPage` et `deriveOnboardingView` acceptent un `cardPages` optionnel (par défaut `CARD_PAGES`) : le verrou est testé sur toute sa table pendant que `CARD_PAGES` est vide. - **[HIGH] Attente de 150 ms** : Attente glissante : chaque événement relance les 150 ms (`followDataChanges`), le nettoyage désabonne et annule une relecture en attente. - **[HIGH] Abonné qui lève** : `notifyDataChanged` écrit un abonné qui lève au Journal (`logError`) et appelle les suivants ; il ne lève jamais (habitude de journalisation du dépôt). - **[HIGH] Documentation** : Outre la ligne de la table des ADR, `docs/architecture.md` reçoit les lignes que ce changement rendait fausses (`onboardingService`, `useOnboardingChecklist`, nouveau service `dataEvents`, dossier `contexts/`, comptes 25 → 26 et 19 → 20). La réécriture de la section « Onboarding » et le guide utilisateur restent à l'issue 6. - **[HIGH] Paramètres → Utilisateurs inchangé** : `UsersSettingsPage` appelle encore `showChecklist()` directement dans cette issue (le passage par le contexte est l'objet de l'issue 2) ; la relecture du fournisseur au changement de route reprend la préférence. - **[HIGH] Test du câblage du hook** : Relevé en auto-revue : `renderToStaticMarkup` ne lance aucun effet, donc la relecture par route (`pathname` dans les dépendances), l'abonnement au canal et son nettoyage, le rangement du verrou et l'ordre « écrire, puis mettre l'état à jour » des actions n'étaient gardés par aucun test. `useOnboardingChecklist.wiring.test.ts` les épingle avec la même technique que le test d'`executeImport` (hooks React remplacés, effets capturés et lancés à la main) ; les mutations M9 à M12 le prouvent rouge. ## Pour relire - **Dispatch en phase de rendu** (`useOnboardingChecklist.ts`, `SET_CARD_PAGE`) : le verrou de carte est dérivé pendant le rendu et rangé dans le reducer du même composant, ce que React applique avant le commit. `latchCardPage` est idempotent (testé), donc une seule passe. C'est le point le plus inhabituel du diff. - **`vi.mock("react")`** dans `useImportWizard.executeImport.test.ts` et `useOnboardingChecklist.wiring.test.ts` : technique nouvelle dans ce dépôt, limitée à ces deux fichiers, pour exercer le vrai code des hooks sans renderer. Elle prouve ce que le hook demande à React (dépendances, abonnement, nettoyage, dispatch), pas la sémantique de React elle-même (double montage de StrictMode, application d'une mise à jour en phase de rendu). - **Précédence des actions locales** : `hiddenSetAt` / `skippedSetAt` gardent le numéro du dernier chargement lancé au moment de l'action ; un `LOAD_DONE` de numéro inférieur ou égal garde la valeur locale pour ce champ seulement. - **`bannerMode`** rend `none`, et non `complete`, tant que `budgetReady` est faux et que la liste calculée Budget verrouillé se dit complète. - **`DashboardPage`** n'écrit plus son `logWarn` d'échec : le fournisseur écrit une ligne au début d'une série d'échecs. - **À vérifier à la main** (liste QA de l'issue 6) : la règle D9 sur un profil réel qui a des transactions, et une étape cochée en direct, page ouverte, une fois le bandeau livré (issue 2). - `UsersSettingsPage` écrit encore la préférence elle-même (l'issue 2 la fait passer par le contexte) ; la relecture au changement de route la reprend. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 3 commits 2026-10-08 11:36:19 +00:00
Add src/services/dataEvents.ts (ADR 0022): notifyDataChanged() and
subscribeDataChanged(), without React, on the model of logService.subscribe.
A throwing subscriber is logged and does not stop the others.

Notify after every successful write of the tables the getting-started
counters read, plus the import folder: setImportFolder, createSource,
deleteSource, createImportedFile, updateFileStatus, both import deletions,
insertBatch, updateTransactionCategory, applyKeywordWithReassignment (after
COMMIT), the four budget writes and runRestore (after COMMIT, one point for
the three restore modes). executeImport notifies once more when it ends,
succeeded or not. A failed write notifies nothing; transactional writes
notify once withTransaction has returned. applyTemplate notifies once for
the whole template through a private writeBudgetEntry helper.

Each service suite proves the notification after a success and its absence
after a failure; executeImport is driven as a plain function with React's
hooks stubbed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checklist state moves from DashboardPage to OnboardingProvider, mounted
in AppShell around the routed page; pages read it through useOnboarding().
The reducer stays in useOnboardingChecklist:

- loads are numbered and a stale answer is ignored;
- HIDE, SHOW, SKIP and RESUME win over the snapshot of a load started
  before them (#384);
- a refresh keeps the counters and the ready status, and a failed re-read
  after a first success keeps the last state; only the start of a failure
  streak is logged;
- re-read at mount, on every route change and 150 ms after a burst of
  data events.

onboardingService: getOnboardingSnapshot replaces getOnboardingCounters and
is a pure read (skipped steps included, one preference per step, filtered on
the six keys and the value "1"); initializeGuidedPath applies rule D9 once
per profile in one transaction (hidden first, then the flag, ROLLBACK on
failure), awaited once by the first load through a promise shared by
StrictMode's double mount; skipStep / resumeStep are idempotent.

computeChecklist counts skipped rows; the new pure onboardingPath module
holds nextStep, CARD_PAGES (empty for now), stepsOfPage, the card latch,
bannerMode and readOnboardingNavState. The dashboard reads the context and
keeps its compact bar for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: ADR 0022 and changelog for the data-change channel
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
68b90f5f16
ADR 0022 records the channel: its scope, the writes that notify and when,
the 150 ms wait on the subscriber side, stale answers ignored, and who may
subscribe. architecture.md gets its row in the ADR table, plus the rows this
change made wrong (onboardingService, useOnboardingChecklist, the new
dataEvents service, the contexts folder). The changelogs record rule D9: a
profile that already had transactions hides the getting-started checklist
the first time it is opened with this version.

Resolves #396

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-10-08 11:36:26 +00:00
maximus added 1 commit 2026-10-08 11:39:03 +00:00
test(onboarding): pin the provider hook's React wiring
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
21c46b6380
renderToStaticMarkup runs no effect, so the hook's own wiring was guarded by
no test: the route re-read (pathname in the deps), the data-channel
subscription and its cleanup, the card latch stored during render, and the
order "write the preference, then update the state" of hide, show, skip and
resume. React's hooks are stubbed so the hook runs as a plain function on a
prepared state, its effects captured and run by hand.

Resolves #396

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

Auto-revue (autopilot) — verdict : APPROVE

Revue du diff de la PR contre chaque critère d'acceptation de #396. Ce n'est pas une revue indépendante : l'issue reste en status:review, status:approved revient au relecteur.

Première lecture sur 68b90f5 : un manque, le câblage React du hook (constat 1), corrigé avant ce verdict par 21c46b6. Le verdict porte sur 21c46b6.

Critères d'acceptation

  • dataEvents : un abonné reçoit, un désabonné ne reçoit plus, un abonné qui lève n'empêche pas les autres et notifyDataChanged ne lève pas (dataEvents.test.ts).
  • Chaque écriture listée notifie après un succès et pas après un échec, un bloc par service avec db moqué (préférences, sources, fichiers importés, transactions, catégorisation, budget, restauration). executeImport notifie à la fin en succès comme en échec, après les insertions et les statuts de fichiers (useImportWizard.executeImport.test.ts).
  • getOnboardingSnapshot n'écrit rien. Les étapes passées ne comptent que pour les six clés et la valeur "1" : clé inconnue, joker _ de LIKE et valeurs "0", "true", "" testés.
  • initializeGuidedPath : les cinq cas demandés, plus un échec du drapeau qui annule aussi le masquage, un profil réaffiché qui n'est pas masqué une seconde fois, et aucune notification.
  • computeChecklist : passée qui compte, complete avec faites et passées, verrouillée hors total même passée, faite et passée comptée une fois. nextStep saute les faites, les passées et les verrouillées, et rend null à la fin.
  • latchCardPage et bannerMode : table complète dans onboardingPath.test.ts, puis le parcours de bout en bout par deriveOnboardingView (étape cochée page ouverte, navigation, « Passer » vers la même page ou une autre, fin, édition Gratuite, échec de relecture).
  • readOnboardingNavState : null, undefined, une chaîne, {}, { onboarding: null }, { onboarding: { focus: "yes" } } et les cas valides.
  • Reducer : LOAD_DONE périmé ignoré ; HIDE, SHOW, SKIP et RESUME prioritaires sur le snapshot d'un chargement parti avant eux ; un rafraîchissement garde ready et les compteurs ; un échec après succès garde le dernier état.
  • Fournisseur monté dans AppShell autour de <Outlet/>, DashboardPage sur le contexte (tests de rendu) ; tableau de bord inchangé hors règle D9.
  • ADR 0022, sa ligne dans architecture.md, CHANGELOG FR et EN.
  • npm run build et npx vitest run verts en local (1520 → 1680). CI check-frontend : run 499 vert sur 68b90f5 (88 fichiers, 1673 tests), run 500 vert sur 21c46b6 (89 fichiers, 1680 tests), lus dans les journaux des jobs.

Constats

  1. Corrigé dans 21c46b6 : renderToStaticMarkup ne lance aucun effet, et le câblage React du hook n'était gardé par aucun test. Retirer pathname des dépendances de la relecture, l'abonnement au canal ou le rangement du verrou laissait tout vert. useOnboardingChecklist.wiring.test.ts épingle ces points et l'ordre « écrire la préférence, puis mettre l'état à jour » des quatre actions ; les mutations M9 à M12 le prouvent rouge.
  2. Non bloquant : le verrou de carte est rangé par un dispatch en phase de rendu (SET_CARD_PAGE). React l'admet pour le reducer du composant qui rend, et latchCardPage est idempotent (testé), donc une seule passe. Avec CARD_PAGES vide, ce chemin ne s'exécute pas encore en production ; l'issue 3 l'activera avec /import, et c'est là qu'une vérification à la main sous StrictMode vaut le détour.
  3. Non bloquant : vi.mock("react") est une technique nouvelle dans ce dépôt (deux fichiers de test). Elle prouve ce que le hook demande à React, pas la sémantique de React (double montage, mise à jour en phase de rendu).
  4. Non bloquant, documenté dans l'ADR : une écriture en plusieurs requêtes sans transaction qui échoue à mi-chemin ne notifie pas, alors que des lignes ont pu changer ; la notification finale d'executeImport et la relecture au changement de route rattrapent.
  5. À suivre, issue 2 : UsersSettingsPage écrit encore la préférence masquée elle-même ; la relecture au changement de route la reprend.
  6. À suivre, issue 6 : la section « Onboarding » d'architecture.md décrit encore computeChecklist(counters, { importFolderSet, budgetAllowed }) sans skipped, et le guide ne dit rien de la règle D9.

Conventions

  • i18n : aucune chaîne visible ajoutée ; les lignes de Journal sont en anglais, comme dans le reste du dépôt.
  • Logique dans services/ et utils/, état dans le reducer de useOnboardingChecklist, contexte mince ; commentaires en anglais.
  • Sécurité : requêtes paramétrées (le motif LIKE est une constante), aucun secret ni donnée personnelle ajoutés ; location.state n'est lu que par readOnboardingNavState.
  • Pas de code mort ni de reste de débogage ; getOnboardingCounters n'a plus aucune référence.
## Auto-revue (autopilot) — verdict : APPROVE Revue du diff de la PR contre chaque critère d'acceptation de #396. Ce n'est pas une revue indépendante : l'issue reste en `status:review`, `status:approved` revient au relecteur. Première lecture sur `68b90f5` : un manque, le câblage React du hook (constat 1), corrigé avant ce verdict par `21c46b6`. Le verdict porte sur `21c46b6`. ### Critères d'acceptation - [x] `dataEvents` : un abonné reçoit, un désabonné ne reçoit plus, un abonné qui lève n'empêche pas les autres et `notifyDataChanged` ne lève pas (`dataEvents.test.ts`). - [x] Chaque écriture listée notifie après un succès et pas après un échec, un bloc par service avec `db` moqué (préférences, sources, fichiers importés, transactions, catégorisation, budget, restauration). `executeImport` notifie à la fin en succès comme en échec, après les insertions et les statuts de fichiers (`useImportWizard.executeImport.test.ts`). - [x] `getOnboardingSnapshot` n'écrit rien. Les étapes passées ne comptent que pour les six clés et la valeur `"1"` : clé inconnue, joker `_` de LIKE et valeurs `"0"`, `"true"`, `""` testés. - [x] `initializeGuidedPath` : les cinq cas demandés, plus un échec du drapeau qui annule aussi le masquage, un profil réaffiché qui n'est pas masqué une seconde fois, et aucune notification. - [x] `computeChecklist` : passée qui compte, `complete` avec faites et passées, verrouillée hors total même passée, faite et passée comptée une fois. `nextStep` saute les faites, les passées et les verrouillées, et rend `null` à la fin. - [x] `latchCardPage` et `bannerMode` : table complète dans `onboardingPath.test.ts`, puis le parcours de bout en bout par `deriveOnboardingView` (étape cochée page ouverte, navigation, « Passer » vers la même page ou une autre, fin, édition Gratuite, échec de relecture). - [x] `readOnboardingNavState` : `null`, `undefined`, une chaîne, `{}`, `{ onboarding: null }`, `{ onboarding: { focus: "yes" } }` et les cas valides. - [x] Reducer : `LOAD_DONE` périmé ignoré ; `HIDE`, `SHOW`, `SKIP` et `RESUME` prioritaires sur le snapshot d'un chargement parti avant eux ; un rafraîchissement garde `ready` et les compteurs ; un échec après succès garde le dernier état. - [x] Fournisseur monté dans `AppShell` autour de `<Outlet/>`, `DashboardPage` sur le contexte (tests de rendu) ; tableau de bord inchangé hors règle D9. - [x] ADR 0022, sa ligne dans `architecture.md`, CHANGELOG FR et EN. - [x] `npm run build` et `npx vitest run` verts en local (1520 → 1680). CI `check-frontend` : run 499 vert sur `68b90f5` (88 fichiers, 1673 tests), run 500 vert sur `21c46b6` (89 fichiers, 1680 tests), lus dans les journaux des jobs. ### Constats 1. **Corrigé dans `21c46b6`** : `renderToStaticMarkup` ne lance aucun effet, et le câblage React du hook n'était gardé par aucun test. Retirer `pathname` des dépendances de la relecture, l'abonnement au canal ou le rangement du verrou laissait tout vert. `useOnboardingChecklist.wiring.test.ts` épingle ces points et l'ordre « écrire la préférence, puis mettre l'état à jour » des quatre actions ; les mutations M9 à M12 le prouvent rouge. 2. **Non bloquant** : le verrou de carte est rangé par un dispatch en phase de rendu (`SET_CARD_PAGE`). React l'admet pour le reducer du composant qui rend, et `latchCardPage` est idempotent (testé), donc une seule passe. Avec `CARD_PAGES` vide, ce chemin ne s'exécute pas encore en production ; l'issue 3 l'activera avec `/import`, et c'est là qu'une vérification à la main sous StrictMode vaut le détour. 3. **Non bloquant** : `vi.mock("react")` est une technique nouvelle dans ce dépôt (deux fichiers de test). Elle prouve ce que le hook demande à React, pas la sémantique de React (double montage, mise à jour en phase de rendu). 4. **Non bloquant, documenté dans l'ADR** : une écriture en plusieurs requêtes sans transaction qui échoue à mi-chemin ne notifie pas, alors que des lignes ont pu changer ; la notification finale d'`executeImport` et la relecture au changement de route rattrapent. 5. **À suivre, issue 2** : `UsersSettingsPage` écrit encore la préférence masquée elle-même ; la relecture au changement de route la reprend. 6. **À suivre, issue 6** : la section « Onboarding » d'`architecture.md` décrit encore `computeChecklist(counters, { importFolderSet, budgetAllowed })` sans `skipped`, et le guide ne dit rien de la règle D9. ### Conventions - i18n : aucune chaîne visible ajoutée ; les lignes de Journal sont en anglais, comme dans le reste du dépôt. - Logique dans `services/` et `utils/`, état dans le reducer de `useOnboardingChecklist`, contexte mince ; commentaires en anglais. - Sécurité : requêtes paramétrées (le motif LIKE est une constante), aucun secret ni donnée personnelle ajoutés ; `location.state` n'est lu que par `readOnboardingNavState`. - Pas de code mort ni de reste de débogage ; `getOnboardingCounters` n'a plus aucune référence.
Author
Owner

Revue adversariale — verdict : APPROVE

Le canal de données, le fournisseur partagé et la règle D9 font ce que décrit #396. La suite et la CI sont vertes, et mes mutations sur le reducer, le chargeur et les règles pures rougissent. Aucun bloquant : un point de vigilance sur le verrou de db.ts (D9 ouvre une transaction à chaque ouverture), et une famille d'épingles que le harnais vi.mock("react") ne peut pas poser.

Ce qui a tourné

  • Copie hors dépôt sur la tête 21c46b6 (npm ci) : npx vitest run 89 fichiers, 1680 tests verts ; npm run build vert.
  • CI check-frontend, run 500 sur 21c46b6, lue dans le journal du job : checkout de 21c46b6, build vite, 89 fichiers et 1680 tests. Aucun fichier Rust touché, pas de check-rust attendu.
  • Timeline de #396 : pull_ref closes depuis #402, l'issue se fermera au merge. Quatre commits au format conventionnel.
  • Douze mutations du relecteur, une à la fois, git diff non vide vérifié, fichier restauré par git checkout :
# Mutation Suites lancées Rouges
RM1 D9 tient pour posé un drapeau de n'importe quelle valeur (flag.length > 0) onboardingService 0/27
RM2 runRestore notifie dans le rappel, juste après le COMMIT, verrou tenu dataExportService 0/40
RM3 le chargeur est recréé à chaque rendu (plus de useState) hook, wiring, contexte 0/54
RM4 state.skipped retiré des dépendances du useMemo de checklist hook, wiring, contexte 0/54
RM5 SKIP et RESUME n'enregistrent plus skippedSetAt useOnboardingChecklist 2/44
RM6 un succès périmé clôt la série d'échecs useOnboardingChecklist 0/44
RM7 l'échec d'un chargement périmé est journalisé useOnboardingChecklist 1/44
RM8 bannerMode annonce la fin avant de regarder la carte verrouillée onboardingPath, hook 2/81
RM9 D9 lit le drapeau avant le BEGIN onboardingService 1/27
RM10 isEmptyDashboard ignore le nombre de transactions hook, wiring 1/51
RM11 une ligne verrouillée et passée entre dans le total onboardingChecklist, onboardingPath 1/57
RM12 mode retiré des dépendances de la valeur partagée hook, wiring, contexte, Dashboard 0/59

Six rouges sur douze. Les survivantes sont traitées ci-dessous ; RM6 est sans conséquence (une ligne de Journal de plus ou de moins).

1. Point de vigilance : D9 ouvre une transaction à chaque ouverture, et db.ts laisse passer hors verrou toute requête lancée pendant ce temps

  • initializeGuidedPath fait BEGIN, lecture du drapeau, COMMIT à chaque montage du fournisseur : démarrage, changement, création ou suppression de profil, et pas seulement à la première ouverture. Le test « with the flag already set » attend ["BEGIN", "COMMIT"]. C'est la première transaction que l'application ouvre sans action de l'utilisateur (les six autres sites de withTransaction partent d'un clic).
  • Pendant le rappel d'un withTransaction, inTransaction vaut true pour tout le module, et le proxy de db.ts exécute alors directement tout execute ou select, d'où qu'il vienne, pas seulement ceux du rappel.
  • Sonde, dans la copie hors dépôt : vrai db.ts, vrai initializeGuidedPath, Database factice à 5 ms par appel. Une lecture getPreference lancée 2 ms après le début de D9 part pendant le BEGIN (« in flight: 2 »), puis la lecture du drapeau part pendant cette lecture. Or db.ts donne l'invariant « jamais deux opérations en même temps » comme ce qui garde le pool sqlx sur une seule connexion et empêche un BEGIN et son COMMIT de partir sur deux connexions, la cause du « database is locked » de la 0.10.0.
  • Portée : à l'ouverture sur le tableau de bord, j'ai suivi l'ordre des microtâches. Les premières requêtes de la page entrent dans la file avant que le rappel de D9 ne démarre, et attendent derrière lui. Je n'ai donc pas trouvé de déclencheur concret : il faut une lecture lancée après une attente qui n'est pas une requête (un invoke, un minuteur) et qui tombe dans les quelques millisecondes de D9. La fenêtre s'ouvre tout de même à chaque démarrage et à chaque changement de profil.
  • Correctif de quelques lignes, compatible avec CWE-362 : lire le drapeau d'abord par getPreference, hors transaction, et n'ouvrir la transaction, qui le relit, que s'il manque. La transaction ne tourne plus qu'une fois par profil, ce qui rejoint le mot « relecture » du corps de l'issue. Le test du drapeau posé attendrait alors aucune instruction et aucun appel à withTransaction.
  • Hors périmètre, à suivre en issue : réserver le passage direct de db.ts au handle remis au rappel, plutôt qu'à un drapeau global. Le fournisseur ajoute aussi une relecture (quatre lectures parallèles) à chaque changement de page : une navigation pendant une restauration longue les enverrait hors verrou, comme le faisaient déjà les pages qui lisent à leur montage.

2. Le harnais vi.mock("react") ne voit ni l'identité du chargeur ni les dépendances des useMemo (RM3, RM4, RM12)

Le useState factice rappelle l'initialiseur à chaque appel, et le useMemo factice recalcule toujours. Le code est correct aujourd'hui, mais trois régressions d'une ligne passeraient au vert :

  • RM3 : en vrai, refresh changerait à chaque rendu, l'effet [refresh, pathname] relirait à chaque rendu, chaque relecture redéclencherait un rendu (boucle de lectures), et D9 repartirait à chaque chargement.
  • RM4 : après « Passer », l'étape suivante ne bougerait pas avant la relecture suivante (changement de page ou écriture). Sur la tête de pile (#407), OnboardingStepCard appelle skip : cette dépendance devient ce qui fait avancer la carte.
  • RM12 : un changement de mode seul (de / vide vers /reports) laisserait l'ancien mode aux consommateurs jusqu'au changement d'une autre dépendance, la relecture qui suit la navigation, ou jamais si elle échoue.

Un harnais à emplacements persistants entre deux appels du hook (useState initialisé une fois, mémo recalculé seulement quand une dépendance change) ferait rougir les trois. À poser dans la pile ou dans une issue de suivi.

3. Épingles plus petites

  • RM2 : le test de runRestore vérifie que la notification suit le COMMIT, pas qu'elle part une fois le verrou relâché ; celui d'applyKeywordWithReassignment le vérifie (inLock). La règle de l'ADR n'est gardée que pour la catégorisation.
  • RM1 : la décision « le drapeau D9 ne compte que pour "1" » n'a pas de test. Enjeu faible : rien n'écrit d'autre valeur.

4. Inventaire de l'ADR 0022

Le tableau se dit construit depuis les tables que lit COUNTERS_SQL, mais deux écritures de ces tables ne figurent ni parmi celles qui notifient ni parmi les exclusions :

  • categoryMigrationService.applyMigration (DELETE FROM budget_entries, UPDATE budget_entries), sur sa propre page, donc rattrapée par la relecture à la navigation ;
  • transactionService.deleteTransaction (DELETE FROM transactions), sans appelant aujourd'hui.

Les nommer dans les exclusions, avec leur raison, garde vérifiable la règle « cette liste ne doit pas rétrécir ».

Vérifié sans remarque

  • Sécurité : requêtes paramétrées, motif LIKE constant puis filtre exact sur les six clés, aucun secret ni donnée personnelle ajoutés, location.state lu par readOnboardingNavState seulement.
  • Les écritures listées par l'issue notifient après succès et se taisent après un échec ; aucune n'est appelée dans un rappel de withTransaction (appelants relevés un par un) ; aucune écriture du parcours ne notifie.
  • Priorité des actions locales sur les snapshots, vérifiée à la main sur les ordres de course entre « Masquer » et une relecture. Le verrou rangé en phase de rendu converge en une passe : latchCardPage est idempotent sur ses quatre branches.
  • i18n : aucune chaîne visible ajoutée. CHANGELOG FR et EN ; le libellé « Afficher la liste de démarrage » est celui de fr.json.
  • Aucune migration, aucun changement Rust.

Recommandation

Merge possible en l'état. Je recommande d'appliquer le point 1 (chemin rapide de D9) en tête de pile avant /merge-stack, et d'ouvrir une issue de suivi pour le point 2 et pour le drapeau global de db.ts.

Actions faites

  • Copie hors dépôt /tmp/pr402-review, script de mutations, sonde et journaux /tmp/pr402-* : supprimés en fin de revue.
  • #396 : status:review → status:approved.
## Revue adversariale — verdict : APPROVE Le canal de données, le fournisseur partagé et la règle D9 font ce que décrit #396. La suite et la CI sont vertes, et mes mutations sur le reducer, le chargeur et les règles pures rougissent. Aucun bloquant : un point de vigilance sur le verrou de `db.ts` (D9 ouvre une transaction à chaque ouverture), et une famille d'épingles que le harnais `vi.mock("react")` ne peut pas poser. ### Ce qui a tourné - Copie hors dépôt sur la tête `21c46b6` (`npm ci`) : `npx vitest run` 89 fichiers, 1680 tests verts ; `npm run build` vert. - CI `check-frontend`, run 500 sur `21c46b6`, lue dans le journal du job : checkout de `21c46b6`, build vite, 89 fichiers et 1680 tests. Aucun fichier Rust touché, pas de `check-rust` attendu. - Timeline de #396 : `pull_ref` `closes` depuis #402, l'issue se fermera au merge. Quatre commits au format conventionnel. - Douze mutations du relecteur, une à la fois, `git diff` non vide vérifié, fichier restauré par `git checkout` : | # | Mutation | Suites lancées | Rouges | |---|---|---|---| | RM1 | D9 tient pour posé un drapeau de n'importe quelle valeur (`flag.length > 0`) | onboardingService | **0/27** | | RM2 | `runRestore` notifie dans le rappel, juste après le `COMMIT`, verrou tenu | dataExportService | **0/40** | | RM3 | le chargeur est recréé à chaque rendu (plus de `useState`) | hook, wiring, contexte | **0/54** | | RM4 | `state.skipped` retiré des dépendances du `useMemo` de `checklist` | hook, wiring, contexte | **0/54** | | RM5 | `SKIP` et `RESUME` n'enregistrent plus `skippedSetAt` | useOnboardingChecklist | 2/44 | | RM6 | un succès périmé clôt la série d'échecs | useOnboardingChecklist | 0/44 | | RM7 | l'échec d'un chargement périmé est journalisé | useOnboardingChecklist | 1/44 | | RM8 | `bannerMode` annonce la fin avant de regarder la carte verrouillée | onboardingPath, hook | 2/81 | | RM9 | D9 lit le drapeau avant le `BEGIN` | onboardingService | 1/27 | | RM10 | `isEmptyDashboard` ignore le nombre de transactions | hook, wiring | 1/51 | | RM11 | une ligne verrouillée et passée entre dans le total | onboardingChecklist, onboardingPath | 1/57 | | RM12 | `mode` retiré des dépendances de la valeur partagée | hook, wiring, contexte, Dashboard | **0/59** | Six rouges sur douze. Les survivantes sont traitées ci-dessous ; RM6 est sans conséquence (une ligne de Journal de plus ou de moins). ### 1. Point de vigilance : D9 ouvre une transaction à chaque ouverture, et `db.ts` laisse passer hors verrou toute requête lancée pendant ce temps - `initializeGuidedPath` fait `BEGIN`, lecture du drapeau, `COMMIT` à chaque montage du fournisseur : démarrage, changement, création ou suppression de profil, et pas seulement à la première ouverture. Le test « with the flag already set » attend `["BEGIN", "COMMIT"]`. C'est la première transaction que l'application ouvre sans action de l'utilisateur (les six autres sites de `withTransaction` partent d'un clic). - Pendant le rappel d'un `withTransaction`, `inTransaction` vaut `true` pour tout le module, et le proxy de `db.ts` exécute alors directement **tout** `execute` ou `select`, d'où qu'il vienne, pas seulement ceux du rappel. - Sonde, dans la copie hors dépôt : vrai `db.ts`, vrai `initializeGuidedPath`, `Database` factice à 5 ms par appel. Une lecture `getPreference` lancée 2 ms après le début de D9 part pendant le `BEGIN` (« in flight: 2 »), puis la lecture du drapeau part pendant cette lecture. Or `db.ts` donne l'invariant « jamais deux opérations en même temps » comme ce qui garde le pool sqlx sur une seule connexion et empêche un `BEGIN` et son `COMMIT` de partir sur deux connexions, la cause du « database is locked » de la 0.10.0. - Portée : à l'ouverture sur le tableau de bord, j'ai suivi l'ordre des microtâches. Les premières requêtes de la page entrent dans la file avant que le rappel de D9 ne démarre, et attendent derrière lui. Je n'ai donc pas trouvé de déclencheur concret : il faut une lecture lancée après une attente qui n'est pas une requête (un `invoke`, un minuteur) et qui tombe dans les quelques millisecondes de D9. La fenêtre s'ouvre tout de même à chaque démarrage et à chaque changement de profil. - Correctif de quelques lignes, compatible avec CWE-362 : lire le drapeau d'abord par `getPreference`, hors transaction, et n'ouvrir la transaction, qui le relit, que s'il manque. La transaction ne tourne plus qu'une fois par profil, ce qui rejoint le mot « relecture » du corps de l'issue. Le test du drapeau posé attendrait alors aucune instruction et aucun appel à `withTransaction`. - Hors périmètre, à suivre en issue : réserver le passage direct de `db.ts` au handle remis au rappel, plutôt qu'à un drapeau global. Le fournisseur ajoute aussi une relecture (quatre lectures parallèles) à chaque changement de page : une navigation pendant une restauration longue les enverrait hors verrou, comme le faisaient déjà les pages qui lisent à leur montage. ### 2. Le harnais `vi.mock("react")` ne voit ni l'identité du chargeur ni les dépendances des `useMemo` (RM3, RM4, RM12) Le `useState` factice rappelle l'initialiseur à chaque appel, et le `useMemo` factice recalcule toujours. Le code est correct aujourd'hui, mais trois régressions d'une ligne passeraient au vert : - RM3 : en vrai, `refresh` changerait à chaque rendu, l'effet `[refresh, pathname]` relirait à chaque rendu, chaque relecture redéclencherait un rendu (boucle de lectures), et D9 repartirait à chaque chargement. - RM4 : après « Passer », l'étape suivante ne bougerait pas avant la relecture suivante (changement de page ou écriture). Sur la tête de pile (#407), `OnboardingStepCard` appelle `skip` : cette dépendance devient ce qui fait avancer la carte. - RM12 : un changement de mode seul (de `/` vide vers `/reports`) laisserait l'ancien mode aux consommateurs jusqu'au changement d'une autre dépendance, la relecture qui suit la navigation, ou jamais si elle échoue. Un harnais à emplacements persistants entre deux appels du hook (`useState` initialisé une fois, mémo recalculé seulement quand une dépendance change) ferait rougir les trois. À poser dans la pile ou dans une issue de suivi. ### 3. Épingles plus petites - RM2 : le test de `runRestore` vérifie que la notification suit le `COMMIT`, pas qu'elle part une fois le verrou relâché ; celui d'`applyKeywordWithReassignment` le vérifie (`inLock`). La règle de l'ADR n'est gardée que pour la catégorisation. - RM1 : la décision « le drapeau D9 ne compte que pour `"1"` » n'a pas de test. Enjeu faible : rien n'écrit d'autre valeur. ### 4. Inventaire de l'ADR 0022 Le tableau se dit construit depuis les tables que lit `COUNTERS_SQL`, mais deux écritures de ces tables ne figurent ni parmi celles qui notifient ni parmi les exclusions : - `categoryMigrationService.applyMigration` (`DELETE FROM budget_entries`, `UPDATE budget_entries`), sur sa propre page, donc rattrapée par la relecture à la navigation ; - `transactionService.deleteTransaction` (`DELETE FROM transactions`), sans appelant aujourd'hui. Les nommer dans les exclusions, avec leur raison, garde vérifiable la règle « cette liste ne doit pas rétrécir ». ### Vérifié sans remarque - Sécurité : requêtes paramétrées, motif `LIKE` constant puis filtre exact sur les six clés, aucun secret ni donnée personnelle ajoutés, `location.state` lu par `readOnboardingNavState` seulement. - Les écritures listées par l'issue notifient après succès et se taisent après un échec ; aucune n'est appelée dans un rappel de `withTransaction` (appelants relevés un par un) ; aucune écriture du parcours ne notifie. - Priorité des actions locales sur les snapshots, vérifiée à la main sur les ordres de course entre « Masquer » et une relecture. Le verrou rangé en phase de rendu converge en une passe : `latchCardPage` est idempotent sur ses quatre branches. - i18n : aucune chaîne visible ajoutée. CHANGELOG FR et EN ; le libellé « Afficher la liste de démarrage » est celui de `fr.json`. - Aucune migration, aucun changement Rust. ### Recommandation Merge possible en l'état. Je recommande d'appliquer le point 1 (chemin rapide de D9) en tête de pile avant `/merge-stack`, et d'ouvrir une issue de suivi pour le point 2 et pour le drapeau global de `db.ts`. ### Actions faites - Copie hors dépôt `/tmp/pr402-review`, script de mutations, sonde et journaux `/tmp/pr402-*` : supprimés en fin de revue. - #396 : `status:review` → `status:approved`.
maximus merged commit 21c46b6380 into main 2026-10-09 01:10:05 +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#402
No description provided.