Onboarding : état partagé du parcours, relecture en direct et canal d'événements de données (ADR 0022) #396

Closed
opened 2026-10-07 23:49:58 +00:00 by maximus · 0 comments
Owner

Milestone planned-2026-10-06-onboarding-parcours-guide, issue 1/6. Phase 2 de l'onboarding (#391) : bandeau de démarrage sur chaque page, et cartes d'étape avec mise en évidence. La spec (spec-decisions-onboarding-parcours-guide.md et spec-plan-onboarding-parcours-guide.md) est locale et ignorée par git : ce corps est auto-suffisant.

Aujourd'hui, l'état de la liste de démarrage ne vit que dans DashboardPage, qui le lit à son montage (useOnboardingChecklist, appelé à DashboardPage.tsx:51). Pour qu'un bandeau et des cartes suivent l'utilisateur sur toutes les pages et voient une étape se cocher en direct, cet état devient partagé par toute l'application et se relit après chaque écriture concernée. Cette issue ne change rien de visible, sauf la règle D9 (profils déjà actifs masqués d'office).

Prérequis hors milestone, déjà rempli : la PR #394 est mergée sur main (5910387). L'étape 5 se détecte donc correctement, et les profils secondaires ont rattrapé leurs migrations.

À faire

  1. src/services/dataEvents.ts (nouveau) : notifyDataChanged() et subscribeDataChanged(listener): () => void, sans React, sur le modèle de logService.subscribe (logService.ts:144-147). Un abonné qui lève n'empêche pas les autres. Le nom est côté données : la couche de données ignore qui écoute.
  2. ADR 0022 docs/adr/0022-canal-evenements-donnees.md : portée du canal (« des données que lisent des compteurs ont peut-être changé »), liste des écritures qui notifient, attente de 150 ms côté abonné, réponses périmées ignorées, abonnés admis. Ajouter sa ligne à la table des ADR de docs/architecture.md.
  3. Notifications. notifyDataChanged() part après chaque écriture réussie de cette liste, construite depuis les tables que lit COUNTERS_SQL (onboardingService.ts:39-46) plus le dossier d'import :
    • setImportFolder (userPreferenceService.ts) ;
    • createSource et deleteSource (importSourceService.ts) : updateSource ne change aucun compte ;
    • createImportedFile, updateFileStatus, deleteImportWithTransactions et deleteAllImportsWithTransactions (importedFileService.ts) ;
    • insertBatch et updateTransactionCategory (transactionService.ts) ;
    • applyKeywordWithReassignment (categorizationService.ts, après son COMMIT) ;
    • upsertBudgetEntry, deleteBudgetEntry, upsertBudgetEntriesForYear et applyTemplate (budgetService.ts) ;
    • runRestore après son COMMIT (dataExportService.ts) : un seul point couvre les trois modes de restauration ;
    • une notification de plus à la fin d'executeImport (useImportWizard.ts), en succès comme en échec.
  4. src/services/onboardingService.ts :
    • getOnboardingSnapshot() remplace getOnboardingCounters(). C'est une lecture pure, qui n'écrit rien : compteurs, dossier choisi, masquage, étapes passées.
    • Étapes passées : une préférence par étape, onboarding_skipped_<clé> = "1". Elles se lisent en une requête (key LIKE 'onboarding_skipped_%'), filtrée sur les six ChecklistItemKey et sur la valeur "1". skipStep(key) et resumeStep(key) écrivent ou effacent la préférence, de façon idempotente. Pas de JSON.
    • initializeGuidedPath() (règle D9) s'exécute dans un withTransaction : BEGIN, relecture d'onboarding_guided_initialized. S'il est absent : onboarding_checklist_hidden = "1" d'abord si transactions > 0, puis onboarding_guided_initialized = "1", puis COMMIT. La fonction rend { hidden }, écrit une ligne au Journal (logInfo) quand elle masque, et ne notifie pas.
    • hideChecklist, showChecklist, skipStep et resumeStep ne notifient pas : le fournisseur met son état à jour lui-même.
  5. src/utils/onboardingChecklist.ts : computeChecklist(counters, { importFolderSet, budgetAllowed, skipped }).
    • Chaque ligne porte done, skipped et locked.
    • Le compteur affiché compte les lignes déverrouillées faites ou passées ; complete les exige toutes.
    • Une ligne verrouillée reste hors total, même passée.
    • Une ligne faite et passée compte une fois.
  6. src/utils/onboardingPath.ts (nouveau, pur) :
    • nextStep(checklist) : première ligne déverrouillée ni faite ni passée, ou null. Le lien d'une étape est ChecklistItem.to.
    • CARD_PAGES, un ensemble vide dans cette issue ; les issues de carte y ajoutent leur page.
    • stepsOfPage(pathname) : /import porte importFolder, source et import ; /transactions porte manualCategory ; /budget porte budget.
    • latchCardPage(previous, { pathname, nextStep, hidden }), le verrou de carte :
      • il se pose quand pathname est la route de l'étape suivante et figure dans CARD_PAGES ;
      • il se relâche quand la route change, quand le parcours est masqué, et après « Passer » si l'étape suivante n'est plus sur cette page ;
      • une étape qui se coche ne le relâche pas.
    • bannerMode({ status, checklist, hidden, pathname, isEmptyDashboard, budgetReady, cardPage }) rend none, banner, card ou complete.
    • readOnboardingNavState(state: unknown): { focus: boolean; guided: boolean }, seule voie de lecture de location.state (typé any par react-router). null, undefined, une chaîne ou un objet partiel donnent false.
  7. src/hooks/useOnboardingChecklist.ts garde le reducer (règle CLAUDE.md : un hook useReducer par domaine), étendu :
    • le premier chargement attend initializeGuidedPath() une fois, par une promesse partagée contre le double montage de StrictMode ;
    • les chargements sont numérotés, et un LOAD_DONE périmé est ignoré ;
    • les actions locales (HIDE, SHOW, SKIP, RESUME) priment sur le snapshot d'un chargement parti avant elles (course relevée en #384) ;
    • un rafraîchissement garde les compteurs et le statut ready (aujourd'hui LOAD_START repasse en loading, :51-53) ;
    • un échec de relecture après un premier succès garde ready et le dernier état, et n'écrit au Journal qu'au passage en échec ;
    • abonnement à subscribeDataChanged, avec une attente de 150 ms qui regroupe les rafales ;
    • relecture à chaque changement de pathname (useLocation) ;
    • verrou de carte tenu par latchCardPage ;
    • au démontage, nettoyage de l'abonnement et du minuteur.
  8. src/contexts/OnboardingContext.tsx (nouveau) : OnboardingProvider appelle le hook une seule fois et partage son résultat. useOnboarding() rend status, checklist, counters, hidden, budgetReady, nextStep, mode, cardPage, hide, show, skip, resume et refresh. Le fournisseur est monté dans AppShell, autour de <Outlet/> (AppShell.tsx:4-13).
  9. DashboardPage lit useOnboarding() au lieu d'appeler le hook. La barre compacte reste affichée dans cette issue (elle part à l'issue 2).
  10. CHANGELOG FR et EN (Changed / Modifié) : un profil qui avait déjà des transactions avant cette version ne voit plus la liste de démarrage, et peut la réafficher depuis Paramètres → Utilisateurs.

Fichiers concernés

  • src/services/dataEvents.ts (+ dataEvents.test.ts)
  • docs/adr/0022-canal-evenements-donnees.md, docs/architecture.md (table des ADR)
  • src/services/userPreferenceService.ts, importSourceService.ts, importedFileService.ts, transactionService.ts, categorizationService.ts, budgetService.ts, dataExportService.ts (+ leurs tests ; les suites qui moquent db doivent couvrir la notification)
  • src/hooks/useImportWizard.ts (notification en fin d'executeImport)
  • src/services/onboardingService.ts (+ onboardingService.test.ts)
  • src/utils/onboardingChecklist.ts (+ test), src/utils/onboardingPath.ts (+ onboardingPath.test.ts)
  • src/hooks/useOnboardingChecklist.ts (+ useOnboardingChecklist.test.ts)
  • src/contexts/OnboardingContext.tsx
  • src/shared/types/index.ts (états de ligne)
  • src/components/layout/AppShell.tsx, src/pages/DashboardPage.tsx
  • CHANGELOG.md, CHANGELOG.fr.md

Critères d'acceptation

  • dataEvents : un abonné reçoit, un désabonné ne reçoit plus, et un abonné qui lève n'empêche pas les autres (tests).
  • Chaque écriture listée notifie après un succès et pas après un échec (tests par service, db moqué) ; executeImport notifie à la fin, en succès comme en échec.
  • getOnboardingSnapshot n'écrit rien (test). Les étapes passées ne comptent que pour les six clés et la valeur "1".
  • initializeGuidedPath, cinq tests :
    • sans drapeau, avec transactions > 0 : masquage puis initialisation, dans la transaction ;
    • sans drapeau, avec 0 transaction : initialisation seule ;
    • drapeau déjà posé : aucune écriture ;
    • masquage en échec : ROLLBACK, drapeau non posé ;
    • le snapshot suivant revient masqué.
  • computeChecklist couvre les quatre cas du point 5 de « À faire ». nextStep saute les faites, les passées et les verrouillées, et rend null à la fin.
  • latchCardPage et bannerMode couvrent toute la table de vérité :
    • masqué ; premier chargement ; échec après succès ; tableau de bord vide ;
    • page dans CARD_PAGES ou non ;
    • étape cochée page ouverte (le verrou tient) ; navigation (il se relâche) ;
    • « Passer » vers la même page ou une autre ;
    • fin ; budgetReady faux.
  • readOnboardingNavState couvre null, undefined, une chaîne, {}, { onboarding: null }, { onboarding: { focus: "yes" } } et les cas valides.
  • Reducer :
    • un LOAD_DONE périmé est ignoré ;
    • HIDE, SHOW, SKIP et RESUME pendant un chargement priment sur son snapshot ;
    • un rafraîchissement garde ready et les compteurs ;
    • un échec après succès garde le dernier état.
  • Le fournisseur est monté dans AppShell et DashboardPage lit le contexte. Le tableau de bord se comporte comme avant, sauf pour un profil déjà actif, masqué d'office.
  • ADR 0022 écrit et référencé dans architecture.md. CHANGELOG FR et EN.
  • npm run build et npx vitest run verts.

Review caveats

  • Critique (revue de la spec) : sans insertBatch ni updateFileStatus, la dernière relecture d'un import partait avant les insertions de transactions. Ne retirer aucune écriture de la liste.
  • Le verrou de carte (latchCardPage) est ce qui laisse une carte affichée après sa réussite. Sans lui, « Fait. » et « Étape suivante » n'apparaîtraient jamais (issues 3 à 5).
  • location.state est typé any : toute lecture passe par readOnboardingNavState (CWE-476).
  • La règle D9 doit rester atomique (CWE-362) et hors de la lecture : deux premiers chargements concurrents ne doivent pas l'appliquer deux fois.

Décisions prises en planification

  • D9 : à la première ouverture sous cette version, un profil qui a déjà des transactions est masqué d'office, et reste réaffichable.
  • D3 (stockage) : une préférence par étape passée.
  • D8 : un seul état masqué, la préférence onboarding_checklist_hidden par profil ; masquer le bandeau masque la liste, et le réaffichage depuis Paramètres → Utilisateurs rétablit tout.
  • Le fournisseur d'onboarding est le seul écrivain des préférences d'onboarding (hidden, étapes passées, initialisation).
  • Canal nommé côté données (dataEvents), attente de 150 ms, réponses périmées ignorées.
  • Le reducer reste dans hooks/useOnboardingChecklist.ts, avec un fournisseur mince.

Spec source

spec-plan-onboarding-parcours-guide.md (et spec-decisions-onboarding-parcours-guide.md)

Milestone `planned-2026-10-06-onboarding-parcours-guide`, issue 1/6. Phase 2 de l'onboarding (#391) : bandeau de démarrage sur chaque page, et cartes d'étape avec mise en évidence. La spec (`spec-decisions-onboarding-parcours-guide.md` et `spec-plan-onboarding-parcours-guide.md`) est locale et ignorée par git : ce corps est auto-suffisant. Aujourd'hui, l'état de la liste de démarrage ne vit que dans `DashboardPage`, qui le lit à son montage (`useOnboardingChecklist`, appelé à `DashboardPage.tsx:51`). Pour qu'un bandeau et des cartes suivent l'utilisateur sur toutes les pages et voient une étape se cocher en direct, cet état devient partagé par toute l'application et se relit après chaque écriture concernée. Cette issue ne change rien de visible, sauf la règle D9 (profils déjà actifs masqués d'office). Prérequis hors milestone, déjà rempli : la PR #394 est mergée sur `main` (`5910387`). L'étape 5 se détecte donc correctement, et les profils secondaires ont rattrapé leurs migrations. ## À faire 1. **`src/services/dataEvents.ts`** (nouveau) : `notifyDataChanged()` et `subscribeDataChanged(listener): () => void`, sans React, sur le modèle de `logService.subscribe` (`logService.ts:144-147`). Un abonné qui lève n'empêche pas les autres. Le nom est côté données : la couche de données ignore qui écoute. 2. **ADR 0022** `docs/adr/0022-canal-evenements-donnees.md` : portée du canal (« des données que lisent des compteurs ont peut-être changé »), liste des écritures qui notifient, attente de 150 ms côté abonné, réponses périmées ignorées, abonnés admis. Ajouter sa ligne à la table des ADR de `docs/architecture.md`. 3. **Notifications.** `notifyDataChanged()` part après chaque écriture **réussie** de cette liste, construite depuis les tables que lit `COUNTERS_SQL` (`onboardingService.ts:39-46`) plus le dossier d'import : - `setImportFolder` (`userPreferenceService.ts`) ; - `createSource` et `deleteSource` (`importSourceService.ts`) : `updateSource` ne change aucun compte ; - `createImportedFile`, `updateFileStatus`, `deleteImportWithTransactions` et `deleteAllImportsWithTransactions` (`importedFileService.ts`) ; - `insertBatch` et `updateTransactionCategory` (`transactionService.ts`) ; - `applyKeywordWithReassignment` (`categorizationService.ts`, après son `COMMIT`) ; - `upsertBudgetEntry`, `deleteBudgetEntry`, `upsertBudgetEntriesForYear` et `applyTemplate` (`budgetService.ts`) ; - `runRestore` après son `COMMIT` (`dataExportService.ts`) : un seul point couvre les trois modes de restauration ; - une notification de plus à la fin d'`executeImport` (`useImportWizard.ts`), en succès comme en échec. 4. **`src/services/onboardingService.ts`** : - `getOnboardingSnapshot()` remplace `getOnboardingCounters()`. C'est une **lecture pure**, qui n'écrit rien : compteurs, dossier choisi, masquage, étapes passées. - **Étapes passées** : une préférence par étape, `onboarding_skipped_<clé>` = `"1"`. Elles se lisent en une requête (`key LIKE 'onboarding_skipped_%'`), filtrée sur les six `ChecklistItemKey` et sur la valeur `"1"`. `skipStep(key)` et `resumeStep(key)` écrivent ou effacent la préférence, de façon idempotente. Pas de JSON. - `initializeGuidedPath()` (règle D9) s'exécute dans un `withTransaction` : `BEGIN`, relecture d'`onboarding_guided_initialized`. S'il est absent : `onboarding_checklist_hidden = "1"` d'abord si `transactions > 0`, puis `onboarding_guided_initialized = "1"`, puis `COMMIT`. La fonction rend `{ hidden }`, écrit une ligne au Journal (`logInfo`) quand elle masque, et ne notifie pas. - `hideChecklist`, `showChecklist`, `skipStep` et `resumeStep` ne notifient pas : le fournisseur met son état à jour lui-même. 5. **`src/utils/onboardingChecklist.ts`** : `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped })`. - Chaque ligne porte `done`, `skipped` et `locked`. - Le compteur affiché compte les lignes déverrouillées faites ou passées ; `complete` les exige toutes. - Une ligne verrouillée reste hors total, même passée. - Une ligne faite et passée compte une fois. 6. **`src/utils/onboardingPath.ts`** (nouveau, pur) : - `nextStep(checklist)` : première ligne déverrouillée ni faite ni passée, ou `null`. Le lien d'une étape est `ChecklistItem.to`. - `CARD_PAGES`, un ensemble **vide** dans cette issue ; les issues de carte y ajoutent leur page. - `stepsOfPage(pathname)` : `/import` porte `importFolder`, `source` et `import` ; `/transactions` porte `manualCategory` ; `/budget` porte `budget`. - `latchCardPage(previous, { pathname, nextStep, hidden })`, le verrou de carte : - il se pose quand `pathname` est la route de l'étape suivante et figure dans `CARD_PAGES` ; - il se relâche quand la route change, quand le parcours est masqué, et après « Passer » si l'étape suivante n'est plus sur cette page ; - une étape qui se coche ne le relâche pas. - `bannerMode({ status, checklist, hidden, pathname, isEmptyDashboard, budgetReady, cardPage })` rend `none`, `banner`, `card` ou `complete`. - `readOnboardingNavState(state: unknown): { focus: boolean; guided: boolean }`, seule voie de lecture de `location.state` (typé `any` par react-router). `null`, `undefined`, une chaîne ou un objet partiel donnent `false`. 7. **`src/hooks/useOnboardingChecklist.ts`** garde le reducer (règle CLAUDE.md : un hook `useReducer` par domaine), étendu : - le premier chargement attend `initializeGuidedPath()` une fois, par une promesse partagée contre le double montage de StrictMode ; - les chargements sont numérotés, et un `LOAD_DONE` périmé est ignoré ; - les actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`) priment sur le snapshot d'un chargement parti avant elles (course relevée en #384) ; - un rafraîchissement garde les compteurs et le statut `ready` (aujourd'hui `LOAD_START` repasse en `loading`, `:51-53`) ; - un échec de relecture après un premier succès garde `ready` et le dernier état, et n'écrit au Journal qu'au passage en échec ; - abonnement à `subscribeDataChanged`, avec une attente de 150 ms qui regroupe les rafales ; - relecture à chaque changement de `pathname` (`useLocation`) ; - verrou de carte tenu par `latchCardPage` ; - au démontage, nettoyage de l'abonnement et du minuteur. 8. **`src/contexts/OnboardingContext.tsx`** (nouveau) : `OnboardingProvider` appelle le hook une seule fois et partage son résultat. `useOnboarding()` rend `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `nextStep`, `mode`, `cardPage`, `hide`, `show`, `skip`, `resume` et `refresh`. Le fournisseur est monté dans `AppShell`, autour de `<Outlet/>` (`AppShell.tsx:4-13`). 9. **`DashboardPage`** lit `useOnboarding()` au lieu d'appeler le hook. La barre compacte reste affichée dans cette issue (elle part à l'issue 2). 10. **CHANGELOG** FR et EN (Changed / Modifié) : un profil qui avait déjà des transactions avant cette version ne voit plus la liste de démarrage, et peut la réafficher depuis Paramètres → Utilisateurs. ## Fichiers concernés - src/services/dataEvents.ts (+ dataEvents.test.ts) - docs/adr/0022-canal-evenements-donnees.md, docs/architecture.md (table des ADR) - src/services/userPreferenceService.ts, importSourceService.ts, importedFileService.ts, transactionService.ts, categorizationService.ts, budgetService.ts, dataExportService.ts (+ leurs tests ; les suites qui moquent `db` doivent couvrir la notification) - src/hooks/useImportWizard.ts (notification en fin d'`executeImport`) - src/services/onboardingService.ts (+ onboardingService.test.ts) - src/utils/onboardingChecklist.ts (+ test), src/utils/onboardingPath.ts (+ onboardingPath.test.ts) - src/hooks/useOnboardingChecklist.ts (+ useOnboardingChecklist.test.ts) - src/contexts/OnboardingContext.tsx - src/shared/types/index.ts (états de ligne) - src/components/layout/AppShell.tsx, src/pages/DashboardPage.tsx - CHANGELOG.md, CHANGELOG.fr.md ## Critères d'acceptation - [ ] `dataEvents` : un abonné reçoit, un désabonné ne reçoit plus, et un abonné qui lève n'empêche pas les autres (tests). - [ ] Chaque écriture listée notifie après un succès et pas après un échec (tests par service, `db` moqué) ; `executeImport` notifie à la fin, en succès comme en échec. - [ ] `getOnboardingSnapshot` n'écrit rien (test). Les étapes passées ne comptent que pour les six clés et la valeur `"1"`. - [ ] `initializeGuidedPath`, cinq tests : - sans drapeau, avec `transactions > 0` : masquage puis initialisation, dans la transaction ; - sans drapeau, avec 0 transaction : initialisation seule ; - drapeau déjà posé : aucune écriture ; - masquage en échec : `ROLLBACK`, drapeau non posé ; - le snapshot suivant revient masqué. - [ ] `computeChecklist` couvre les quatre cas du point 5 de « À faire ». `nextStep` saute les faites, les passées et les verrouillées, et rend `null` à la fin. - [ ] `latchCardPage` et `bannerMode` couvrent toute la table de vérité : - masqué ; premier chargement ; échec après succès ; tableau de bord vide ; - page dans `CARD_PAGES` ou non ; - étape cochée page ouverte (le verrou tient) ; navigation (il se relâche) ; - « Passer » vers la même page ou une autre ; - fin ; `budgetReady` faux. - [ ] `readOnboardingNavState` couvre `null`, `undefined`, une chaîne, `{}`, `{ onboarding: null }`, `{ onboarding: { focus: "yes" } }` et les cas valides. - [ ] Reducer : - un `LOAD_DONE` périmé est ignoré ; - `HIDE`, `SHOW`, `SKIP` et `RESUME` pendant un chargement priment sur son snapshot ; - un rafraîchissement garde `ready` et les compteurs ; - un échec après succès garde le dernier état. - [ ] Le fournisseur est monté dans `AppShell` et `DashboardPage` lit le contexte. Le tableau de bord se comporte comme avant, sauf pour un profil déjà actif, masqué d'office. - [ ] ADR 0022 écrit et référencé dans `architecture.md`. CHANGELOG FR et EN. - [ ] `npm run build` et `npx vitest run` verts. ## Review caveats - Critique (revue de la spec) : sans `insertBatch` ni `updateFileStatus`, la dernière relecture d'un import partait avant les insertions de transactions. Ne retirer aucune écriture de la liste. - Le verrou de carte (`latchCardPage`) est ce qui laisse une carte affichée après sa réussite. Sans lui, « Fait. » et « Étape suivante » n'apparaîtraient jamais (issues 3 à 5). - `location.state` est typé `any` : toute lecture passe par `readOnboardingNavState` (CWE-476). - La règle D9 doit rester atomique (CWE-362) et hors de la lecture : deux premiers chargements concurrents ne doivent pas l'appliquer deux fois. ## Décisions prises en planification - D9 : à la première ouverture sous cette version, un profil qui a déjà des transactions est masqué d'office, et reste réaffichable. - D3 (stockage) : une préférence par étape passée. - D8 : un seul état masqué, la préférence `onboarding_checklist_hidden` par profil ; masquer le bandeau masque la liste, et le réaffichage depuis Paramètres → Utilisateurs rétablit tout. - Le fournisseur d'onboarding est le seul écrivain des préférences d'onboarding (`hidden`, étapes passées, initialisation). - Canal nommé côté données (`dataEvents`), attente de 150 ms, réponses périmées ignorées. - Le reducer reste dans `hooks/useOnboardingChecklist.ts`, avec un fournisseur mince. ## Spec source spec-plan-onboarding-parcours-guide.md (et spec-decisions-onboarding-parcours-guide.md)
maximus added this to the planned-2026-10-06-onboarding-parcours-guide milestone 2026-10-07 23:49:58 +00:00
maximus added the
status:ready
type:feature
source:human
labels 2026-10-07 23:49:58 +00:00
maximus added
status:review
and removed
status:ready
labels 2026-10-08 11:36:27 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-10-08 23:15:27 +00:00
maximus removed the
status:approved
label 2026-10-09 01:11:26 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

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