feat(onboarding): shared getting-started state, live re-read and data-change channel (ADR 0022) #402
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#402
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-396-onboarding-shared-state"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Resolves #396
Generated autonomously by /autopilot run of 2026-10-08
Ce qui change pour l'utilisateur
Technique
src/services/dataEvents.ts(ADR 0022) :notifyDataChanged()etsubscribeDataChanged(), sans React, sur le modèle delogService.subscribe. Un abonné qui lève est écrit au Journal et n'empêche pas les autres ;notifyDataChangedne lève jamais.setImportFolder,createSource,deleteSource,createImportedFile,updateFileStatus, les deux suppressions d'import,insertBatch,updateTransactionCategory,applyKeywordWithReassignment, les quatre écritures du budget,runRestore), plus une dans lefinallyd'executeImport. Les écritures faites danswithTransactionnotifient après son retour (COMMIT fait, verrou relâché).applyTemplatenotifie une fois pour tout le modèle, par un helper privéwriteBudgetEntry.onboardingService:getOnboardingSnapshotremplacegetOnboardingCounterset ne fait que lire (étapes passées : une préférenceonboarding_skipped_<clé>="1"par étape, lues en une requête et filtrées sur les six clés) ;initializeGuidedPathapplique D9 danswithTransaction(BEGIN, relecture du drapeau, masquage puis drapeau, COMMIT, ROLLBACK sur échec) et écrit une ligne au Journal quand il masque ;skipStepetresumeStepsont idempotents. Aucune des écritures du parcours ne notifie.computeChecklist: optionskipped; chaque ligne portedone,skippedetlocked; 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.useOnboardingChecklistgarde le reducer : chargements numérotés (réponse périmée ignorée),HIDE/SHOW/SKIP/RESUMEprioritaires sur le snapshot d'un chargement parti avant eux, rafraîchissement qui gardereadyet les compteurs, échec après succès qui garde l'état.createOnboardingLoaderattend D9 une fois (promesse partagée) et n'écrit au Journal qu'au passage en échec ;followDataChangesrelit 150 ms après une rafale ; relecture à chaquepathname;deriveOnboardingView(pur) calcule l'étape suivante, le verrou de carte et le mode.src/contexts/OnboardingContext.tsx:OnboardingProvider, monté dansAppShellautour de<Outlet/>, etuseOnboarding().DashboardPagelituseOnboarding(); la barre compacte reste.docs/architecture.mdque ce changement rendait fausses ; CHANGELOG FR et EN (Modifié / Changed).check-rustattendu).Preuves
npm run build: vert (tsc + vite).npx vitest run: 1520 → 1680 tests (80 → 89 fichiers), tous verts.check-frontend: run 499 vert sur68b90f5; le run de la tête21c46b6(test de câblage ajouté après l'auto-revue) est confirmé dans le commentaire de revue.68b90f5, M9 à M12 sur21c46b6), une mutation à la fois,git diffnon vide vérifié, fichier restauré pargit checkout; suites concernées vertes avant la passe.LOAD_DONEHIDE/SHOWne priment plus sur un snapshot plus ancienSKIPne relâche plus le verrouROLLBACKupdateTransactionCategorynotifie avant que son écriture aboutisseexecuteImportsans notification finalehidemet l'état à jour avant son écriture12/12 mutations rouges.
Décisions
applyKeywordWithReassignmentetrunRestorenotifient après le retour dewithTransaction(COMMIT fait, verrou dedb.tsrelâché), jamais dans le rappel : un abonné qui lirait la base pendantinTransactioncontournerait le verrou.createSourcenotifie juste après son UPSERT, avant la relecturegetSourceByNamequi suit un conflit de nom et ne fait que lire.applyTemplatepasse par un helper privéwriteBudgetEntry(l'UPSERT d'upsertBudgetEntrysans notification) et notifie une fois après la dernière entrée ; une entrée en échec ne notifie rien. Écarté : garder l'appel auupsertBudgetEntrypublic (N + 1 notifications, et un échec à l'entrée k > 1 aurait déjà notifié, contre « pas après un échec »).deleteImportWithTransactions,deleteAllImportsWithTransactions,insertBatchetupsertBudgetEntriesForYearnotifient 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'executeImportet la relecture au changement de route rattrapent. Écarté : notifier dans unfinally(contraire au critère « pas après un échec »).useImportWizard.executeImport.test.tsremplace les trois hooks React du module (useReducer,useCallback,useEffect) parvi.mock("react"), appelle le hook comme une fonction sur un état préparé et moque les services : le vraiexecuteImportest 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).skippedoptionnel dans computeChecklist : L'optionskippeddecomputeChecklistest optionnelle (aucune par défaut) : les appelants et tests existants restent inchangés (plan : « adaptés seulement aux nouvelles propriétés optionnelles »).ChecklistItem.skippedest requis.errorseulement avant un premier succès :LOAD_STARTgardereadyaprès un premier succès, et repasse enloadingdepuiserror(comportement et test existants conservés). Écarté : gardererrorpendant une nouvelle tentative (aurait changé un comportement testé sans demande).errorretiré, avertissement du tableau de bord retiré : Le reducer n'a plus de champerroretDashboardPagen'écrit plus sonlogWarn: le chargeur du fournisseur écrit une seule lignelogErrorau début d'une série d'échecs. LelogWarndu tableau de bord aurait ajouté une ligne à chaque visite pendant la panne, contre « une seule ligne au Journal au passage en échec ». Écarté : exposererrordans le contexte (absent de la liste deuseOnboarding()de l'issue).initializeGuidedPathqui é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).onboarding_guided_initializedcompte comme posé seulement pour la valeur "1" (même règle que les autres préférences du parcours) ;initializeGuidedPathrend{ hidden: true }seulement quand cet appel a masqué le parcours. Le drapeau déjà posé donne BEGIN puis COMMIT, sans écriture.useStatedu fournisseur (même motif querevocationCheckLauncherdeLicenseContext), pas au niveau du module : le double montage de StrictMode la partage, et un changement de profil (remontage d'AppShell) initialise le nouveau profil.latchCardPage(viaderiveOnboardingView, pur et testé) et rangé dans le reducer par un dispatch en phase de rendu (SET_CARD_PAGE, idempotent, une seule passe) ;SKIPle 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).bannerModerendnone(et noncomplete) tant quebudgetReadyest faux et que la liste calculée Budget verrouillé se dit complète : la fin peut être à une étape Budget près. Il rendbannerquand des étapes restent (le compteur du bandeau attendrabudgetReady, issue 2). Écarté :bannersans étape suivante.cardPagesinjectable :latchCardPageetderiveOnboardingViewacceptent uncardPagesoptionnel (par défautCARD_PAGES) : le verrou est testé sur toute sa table pendant queCARD_PAGESest vide.followDataChanges), le nettoyage désabonne et annule une relecture en attente.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).docs/architecture.mdreçoit les lignes que ce changement rendait fausses (onboardingService,useOnboardingChecklist, nouveau servicedataEvents, dossiercontexts/, comptes 25 → 26 et 19 → 20). La réécriture de la section « Onboarding » et le guide utilisateur restent à l'issue 6.UsersSettingsPageappelle encoreshowChecklist()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.renderToStaticMarkupne lance aucun effet, donc la relecture par route (pathnamedans 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.tsles é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
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.latchCardPageest idempotent (testé), donc une seule passe. C'est le point le plus inhabituel du diff.vi.mock("react")dansuseImportWizard.executeImport.test.tsetuseOnboardingChecklist.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).hiddenSetAt/skippedSetAtgardent le numéro du dernier chargement lancé au moment de l'action ; unLOAD_DONEde numéro inférieur ou égal garde la valeur locale pour ce champ seulement.bannerModerendnone, et noncomplete, tant quebudgetReadyest faux et que la liste calculée Budget verrouillé se dit complète.DashboardPagen'écrit plus sonlogWarnd'échec : le fournisseur écrit une ligne au début d'une série d'échecs.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
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:approvedrevient au relecteur.Première lecture sur
68b90f5: un manque, le câblage React du hook (constat 1), corrigé avant ce verdict par21c46b6. Le verdict porte sur21c46b6.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 etnotifyDataChangedne lève pas (dataEvents.test.ts).dbmoqué (préférences, sources, fichiers importés, transactions, catégorisation, budget, restauration).executeImportnotifie à la fin en succès comme en échec, après les insertions et les statuts de fichiers (useImportWizard.executeImport.test.ts).getOnboardingSnapshotn'é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,completeavec faites et passées, verrouillée hors total même passée, faite et passée comptée une fois.nextStepsaute les faites, les passées et les verrouillées, et rendnullà la fin.latchCardPageetbannerMode: table complète dansonboardingPath.test.ts, puis le parcours de bout en bout parderiveOnboardingView(é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.LOAD_DONEpérimé ignoré ;HIDE,SHOW,SKIPetRESUMEprioritaires sur le snapshot d'un chargement parti avant eux ; un rafraîchissement gardereadyet les compteurs ; un échec après succès garde le dernier état.AppShellautour de<Outlet/>,DashboardPagesur le contexte (tests de rendu) ; tableau de bord inchangé hors règle D9.architecture.md, CHANGELOG FR et EN.npm run buildetnpx vitest runverts en local (1520 → 1680). CIcheck-frontend: run 499 vert sur68b90f5(88 fichiers, 1673 tests), run 500 vert sur21c46b6(89 fichiers, 1680 tests), lus dans les journaux des jobs.Constats
21c46b6:renderToStaticMarkupne lance aucun effet, et le câblage React du hook n'était gardé par aucun test. Retirerpathnamedes 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.SET_CARD_PAGE). React l'admet pour le reducer du composant qui rend, etlatchCardPageest idempotent (testé), donc une seule passe. AvecCARD_PAGESvide, 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.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).executeImportet la relecture au changement de route rattrapent.UsersSettingsPageécrit encore la préférence masquée elle-même ; la relecture au changement de route la reprend.architecture.mddécrit encorecomputeChecklist(counters, { importFolderSet, budgetAllowed })sansskipped, et le guide ne dit rien de la règle D9.Conventions
services/etutils/, état dans le reducer deuseOnboardingChecklist, contexte mince ; commentaires en anglais.location.staten'est lu que parreadOnboardingNavState.getOnboardingCountersn'a plus aucune référence.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 harnaisvi.mock("react")ne peut pas poser.Ce qui a tourné
21c46b6(npm ci) :npx vitest run89 fichiers, 1680 tests verts ;npm run buildvert.check-frontend, run 500 sur21c46b6, lue dans le journal du job : checkout de21c46b6, build vite, 89 fichiers et 1680 tests. Aucun fichier Rust touché, pas decheck-rustattendu.pull_refclosesdepuis #402, l'issue se fermera au merge. Quatre commits au format conventionnel.git diffnon vide vérifié, fichier restauré pargit checkout:flag.length > 0)runRestorenotifie dans le rappel, juste après leCOMMIT, verrou tenuuseState)state.skippedretiré des dépendances duuseMemodechecklistSKIPetRESUMEn'enregistrent plusskippedSetAtbannerModeannonce la fin avant de regarder la carte verrouilléeBEGINisEmptyDashboardignore le nombre de transactionsmoderetiré des dépendances de la valeur partagéeSix 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.tslaisse passer hors verrou toute requête lancée pendant ce tempsinitializeGuidedPathfaitBEGIN, 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 dewithTransactionpartent d'un clic).withTransaction,inTransactionvauttruepour tout le module, et le proxy dedb.tsexécute alors directement toutexecuteouselect, d'où qu'il vienne, pas seulement ceux du rappel.db.ts, vraiinitializeGuidedPath,Databasefactice à 5 ms par appel. Une lecturegetPreferencelancée 2 ms après le début de D9 part pendant leBEGIN(« in flight: 2 »), puis la lecture du drapeau part pendant cette lecture. Ordb.tsdonne l'invariant « jamais deux opérations en même temps » comme ce qui garde le pool sqlx sur une seule connexion et empêche unBEGINet sonCOMMITde partir sur deux connexions, la cause du « database is locked » de la 0.10.0.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.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.db.tsau 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 desuseMemo(RM3, RM4, RM12)Le
useStatefactice rappelle l'initialiseur à chaque appel, et leuseMemofactice recalcule toujours. Le code est correct aujourd'hui, mais trois régressions d'une ligne passeraient au vert :refreshchangerait à chaque rendu, l'effet[refresh, pathname]relirait à chaque rendu, chaque relecture redéclencherait un rendu (boucle de lectures), et D9 repartirait à chaque chargement.OnboardingStepCardappelleskip: cette dépendance devient ce qui fait avancer la carte./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 (
useStateinitialisé 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
runRestorevérifie que la notification suit leCOMMIT, pas qu'elle part une fois le verrou relâché ; celui d'applyKeywordWithReassignmentle vérifie (inLock). La règle de l'ADR n'est gardée que pour la catégorisation."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
LIKEconstant puis filtre exact sur les six clés, aucun secret ni donnée personnelle ajoutés,location.statelu parreadOnboardingNavStateseulement.withTransaction(appelants relevés un par un) ; aucune écriture du parcours ne notifie.latchCardPageest idempotent sur ses quatre branches.fr.json.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 dedb.ts.Actions faites
/tmp/pr402-review, script de mutations, sonde et journaux/tmp/pr402-*: supprimés en fin de revue.status:review→status:approved.