feat(onboarding): visit steps foundation, eight rows, "Got it" and one-time initialization (#425) #444
38 changed files with 1487 additions and 197 deletions
|
|
@ -5,6 +5,7 @@
|
|||
### Ajouté
|
||||
|
||||
- Import : un avertissement nomme les fichiers CSV posés directement dans le dossier d'import, qui sont ignorés (#427).
|
||||
- Parcours de démarrage : deux étapes de visite, « Parcourir les catégories et les mots-clés » (avant le classement) et « Parcourir les rapports » (en dernier), cochées par « J'ai compris » ; un profil qui avait terminé le parcours les trouve déjà faites (#425).
|
||||
|
||||
### Modifié
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
### Added
|
||||
|
||||
- Import: a warning names the CSV files placed directly in the import folder, which are ignored (#427).
|
||||
- Getting started: two visit steps, “Browse categories and keywords” (before categorizing) and “Browse the reports” (last), checked with “Got it”; a profile that had finished the path finds them already done (#425).
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
- Status: **Accepted**
|
||||
- Date: 2026-10-08
|
||||
- Issues: #396 (milestone `planned-2026-10-06-onboarding-parcours-guide`, phase 2 de l'onboarding, #391)
|
||||
- Issues: #396 (milestone `planned-2026-10-06-onboarding-parcours-guide`, phase 2 de l'onboarding, #391) ; amendé par #425 (étapes de visite, §2, §4 et §5)
|
||||
- S'appuie sur l'[ADR 0002](0002-useReducer-vs-redux.md) (un `useReducer` par domaine, pas de store global) et l'[ADR 0016](0016-persistance-etat-ui-par-profil.md) (préférences par profil dans `user_preferences`)
|
||||
|
||||
## Contexte
|
||||
|
|
@ -45,7 +45,7 @@ Ne notifient pas :
|
|||
- `updateSource`, qui ne change aucun compte ; les notes d'une transaction ; les modèles de budget ; toute écriture d'une table que ne lit aucun compteur ;
|
||||
- `categoryMigrationService.applyMigration`, qui réécrit `budget_entries` (`DELETE` des mois en double sous la catégorie cible, puis `UPDATE` de la catégorie) : il ne tourne que sur sa propre page, la migration des catégories, et la relecture qui suit tout changement de route rattrape le compteur ;
|
||||
- `transactionService.deleteTransaction` (`DELETE FROM transactions`), sans appelant aujourd'hui : son premier appelant l'ajoute au tableau, avec son test ;
|
||||
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, et `initializeGuidedPath`, la règle D9). Le fournisseur en est le seul écrivain et met son état à jour lui-même.
|
||||
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, `acknowledgeStep`, et `initializeGuidedPath`, la règle D9, et `initializeVisitSteps`, l'initialisation des étapes de visite, #425). Le fournisseur en est le seul écrivain et met son état à jour lui-même.
|
||||
|
||||
**Règle pour la suite** : une nouvelle écriture d'une table que lit `COUNTERS_SQL` s'ajoute à ce tableau et notifie, avec un test qui prouve la notification après un succès et son absence après un échec. Une écriture faite dans `withTransaction` notifie après la fin de `withTransaction`, jamais à l'intérieur du rappel : le `COMMIT` est fait et le verrou relâché.
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ L'émetteur n'attend rien. L'abonné attend 150 ms sans nouvel événement avant
|
|||
|
||||
### 4. Les réponses périmées sont ignorées
|
||||
|
||||
Les chargements sont numérotés, et le reducer n'applique que la réponse du dernier lancé : une réponse lente ne remplace jamais une plus récente. Une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`) prime sur le snapshot d'un chargement parti avant elle, lu avant son écriture : c'est la course entre « Masquer » et un rafraîchissement relevée en #384. Une relecture qui échoue après un premier succès garde l'état affiché, et seul le passage en échec est écrit au Journal, pour ne pas chasser une vraie cause hors des dernières lignes jointes à une rétroaction.
|
||||
Les chargements sont numérotés, et le reducer n'applique que la réponse du dernier lancé : une réponse lente ne remplace jamais une plus récente. Une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle, lu avant son écriture : c'est la course entre « Masquer » et un rafraîchissement relevée en #384. Une relecture qui échoue après un premier succès garde l'état affiché, et seul le passage en échec est écrit au Journal, pour ne pas chasser une vraie cause hors des dernières lignes jointes à une rétroaction.
|
||||
|
||||
### 5. Abonnés admis
|
||||
|
||||
|
|
@ -66,6 +66,8 @@ Un abonné :
|
|||
- se désabonne à son démontage ;
|
||||
- ne fait rien de coûteux dans l'appel lui-même, qui est synchrone : il arme un minuteur.
|
||||
|
||||
Une exception admise, comme la règle D9 : le cochage d'office des étapes de visite (#425). Il n'est pas une réponse à une notification, mais une écriture unique par profil (`initializeVisitSteps`), lancée après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, gardée par une promesse partagée contre le double montage de StrictMode, et qui relit elle-même une fois écrite. Elle ne notifie pas, et ses écritures sont idempotentes, sans transaction : un arrêt entre deux refait le même calcul à l'ouverture suivante.
|
||||
|
||||
Aujourd'hui, un seul abonné : le fournisseur du parcours de démarrage (`OnboardingProvider`, par `useOnboardingChecklist`). Un abonné qui aurait besoin d'autres tables élargit la portée du canal : il amende cet ADR, avec la liste des écritures concernées et leurs tests.
|
||||
|
||||
## Conséquences
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ simpl-resultat/
|
|||
| `budget_templates` | Modèles de budget réutilisables |
|
||||
| `budget_template_entries` | Catégories et montants dans les modèles |
|
||||
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
||||
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand le parcours de démarrage est masqué ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352, #397), `onboarding_skipped_<clé>` (« 1 » par étape passée, #396), `onboarding_guided_initialized` (« 1 » une fois la règle D9 appliquée au profil, #396) — voir la section « Onboarding »… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
|
||||
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand le parcours de démarrage est masqué ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352, #397), `onboarding_skipped_<clé>` (« 1 » par étape passée, #396), `onboarding_guided_initialized` (« 1 » une fois la règle D9 appliquée au profil, #396), `onboarding_acknowledged_<clé>` (« 1 » par étape de visite cochée par « J'ai compris », #425), `onboarding_visit_steps_initialized` (« 1 » une fois les étapes de visite initialisées pour le profil, #425) — voir la section « Onboarding »… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
|
||||
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
|
||||
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
|
||||
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
|
||||
|
|
@ -208,7 +208,7 @@ Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `prof
|
|||
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
||||
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
|
||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||
| `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden` et les é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 la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `hideChecklist` / `showChecklist` et `skipStep` / `resumeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » |
|
||||
| `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden`, les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les huit clés), les étapes de visite reconnues (`onboarding_acknowledged_<clé>` = `"1"`, lues en une requête et filtrées sur `VISIT_STEP_KEYS`, #425) et le drapeau `onboarding_visit_steps_initialized` ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `initializeVisitSteps(acknowledge)` initialise les étapes de visite une fois par profil, **sans transaction** (les deux reconnaissances si `acknowledge`, puis le drapeau, par écritures idempotentes) ; `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep` et `acknowledgeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en huit lignes — voir la section « Onboarding » |
|
||||
| `dataEvents.ts` | Canal d'événements de données (#396, [ADR 0022](adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` après chaque écriture réussie d'une table que lit un compteur du parcours de démarrage (liste dans l'ADR), `subscribeDataChanged(listener)` rend le désabonnement. Sans React ni charge utile ; un abonné qui lève est écrit au Journal et n'empêche pas les autres |
|
||||
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
||||
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine |
|
||||
|
|
@ -240,7 +240,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useAdjustments` | Ajustements |
|
||||
| `useBudget` | Budget |
|
||||
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
|
||||
| `useOnboardingChecklist` | Parcours de démarrage (#351, #396), appelé une seule fois par `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`, monté dans `AppShell` autour de `<Outlet/>`) ; les pages le lisent par `useOnboarding()`. Reducer exporté (`onboardingChecklistReducer`) : chargements numérotés (la réponse d'un chargement autre que le dernier lancé est ignorée), actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`) prioritaires sur le snapshot d'un chargement parti avant elles, rafraîchissement qui garde `ready` et les compteurs, échec après un premier succès qui garde le dernier état. `createOnboardingLoader` attend la règle D9 au premier chargement, une fois (promesse partagée contre le double montage de StrictMode), et n'écrit au Journal qu'au passage en échec. Relecture au montage, à chaque changement de route et 150 ms après une rafale d'événements de données (`followDataChanges`, [ADR 0022](adr/0022-canal-evenements-donnees.md)). `computeChecklist` reçoit `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total) et les étapes passées ; `deriveOnboardingView` (pur) en tire l'étape suivante, le verrou de carte (`latchCardPage`) et le mode (`bannerMode`, `utils/onboardingPath.ts`). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `nextStep`, `mode`, `cardPage`, `hide()` / `show()` / `skip()` / `resume()` / `refresh()` |
|
||||
| `useOnboardingChecklist` | Parcours de démarrage (#351, #396), appelé une seule fois par `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`, monté dans `AppShell` autour de `<Outlet/>`) ; les pages le lisent par `useOnboarding()`. Reducer exporté (`onboardingChecklistReducer`) : chargements numérotés (la réponse d'un chargement autre que le dernier lancé est ignorée), actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prioritaires sur le snapshot d'un chargement parti avant elles (`ACKNOWLEDGE` garde le verrou de carte, contrairement à `SKIP`), rafraîchissement qui garde `ready` et les compteurs, échec après un premier succès qui garde le dernier état. `createOnboardingLoader` attend la règle D9 au premier chargement, une fois (promesse partagée contre le double montage de StrictMode), et n'écrit au Journal qu'au passage en échec. `createVisitStepsInitializer` lance `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` une fois par fournisseur (promesse partagée), après le premier chargement réussi où `budgetReady` est vrai et le drapeau absent (`needsVisitStepsInitialization`), puis relit (#425). Relecture au montage, à chaque changement de route et 150 ms après une rafale d'événements de données (`followDataChanges`, [ADR 0022](adr/0022-canal-evenements-donnees.md)). `computeChecklist` reçoit `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total), les étapes passées et reconnues et `visitStepsInitialized` ; `deriveOnboardingView` (pur) en tire l'étape suivante, le verrou de carte (`latchCardPage`) et le mode (`bannerMode`, `utils/onboardingPath.ts`, la fin attendant `budgetReady && visitStepsInitialized`). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, `hide()` / `show()` / `skip()` / `resume()` / `acknowledge()` / `refresh()` |
|
||||
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
|
||||
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
||||
|
|
@ -331,7 +331,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
|||
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||
|
||||
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a six lignes dans un ordre fixe. Chaque case est cochée par une **donnée**, jamais par un clic ; la source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped })` (`utils/onboardingChecklist.ts`, pur) :
|
||||
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a huit lignes dans un ordre fixe (#425). Les six étapes de données se cochent par une **donnée**, jamais par un clic ; les deux étapes de visite par « J'ai compris » (et, pour Rapports, l'ouverture de la carte Tendances mise en évidence). La source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped, acknowledged, visitStepsInitialized })` (`utils/onboardingChecklist.ts`, pur) :
|
||||
|
||||
| Case | Source de vérité |
|
||||
|---|---|
|
||||
|
|
@ -339,16 +339,18 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
|||
| Choisir un dossier d'import | préférence `import_folder` non vide |
|
||||
| Configurer une source d'import | `COUNT(*) FROM import_sources` ≥ 1 |
|
||||
| Importer un premier fichier | `COUNT(*) FROM imported_files f WHERE f.status = 'completed' AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)` ≥ 1 — un fichier qui n'a amené aucune transaction (toutes ses lignes en erreur, ou sa seule ligne d'en-tête) ne compte pas : `executeImport` écrit la ligne `completed` avant d'insérer |
|
||||
| Parcourir les catégories et les mots-clés | étape de visite (#425) : préférence `onboarding_acknowledged_categories` = `"1"`, posée par « J'ai compris » sur sa carte ; verrouillée tant que `visitStepsInitialized` est faux, alors exclue du total |
|
||||
| Classer une transaction à la main | `COUNT(*) FROM transactions WHERE is_manually_categorized = 1` ≥ 1 — ni « règle créée » ni « transactions catégorisées » : le seed IPC pose 180 mots-clés dans tout profil neuf et le wizard catégorise pendant l'import, ces deux cases seraient donc cochées d'avance |
|
||||
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (5 au lieu de 6) et renvoyée vers `/settings/users` |
|
||||
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (7 au lieu de 8) et renvoyée vers `/settings/users` |
|
||||
| Parcourir les rapports | étape de visite (#425) : préférence `onboarding_acknowledged_reports` = `"1"` ; même verrou que la ligne Catégories avant l'initialisation |
|
||||
|
||||
`computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance.
|
||||
`computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Les deux lignes de visite sont verrouillées tant que le profil n'a pas initialisé ses étapes de visite : un profil au parcours fini ne passe pas un instant par « Étape suivante : Parcourir les catégories » ; la liste les montre alors « en attente », jamais « verrouillées » (le badge de l'édition Base ne les concerne pas), et son compteur attend `budgetReady && visitStepsInitialized`. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance.
|
||||
|
||||
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
|
||||
|
||||
### Parcours guidé
|
||||
|
||||
**Le fournisseur.** `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`), monté dans `AppShell` autour du bandeau et de `<Outlet/>`, appelle une seule fois `useOnboardingChecklist` : l'état garde son `useReducer` de domaine ([ADR 0002](adr/0002-useReducer-vs-redux.md)) et toute l'application le partage. Les pages et les composants le lisent par `useOnboarding()`, qui lève hors du fournisseur : `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `nextStep`, `mode`, `cardPage`, et les actions `hide()`, `show()`, `skip(clé)`, `resume(clé)`, `refresh()`. Le fournisseur survit à la navigation ; un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`), donc un fournisseur neuf, avec son propre chargeur. Il relit son état au montage, à chaque changement de route et après les écritures annoncées sur le canal de données. Ses chargements sont numérotés et seule la réponse du dernier lancé s'applique ; une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`) prime sur le snapshot d'un chargement parti avant elle ; une relecture qui échoue après un premier succès garde l'état affiché.
|
||||
**Le fournisseur.** `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`), monté dans `AppShell` autour du bandeau et de `<Outlet/>`, appelle une seule fois `useOnboardingChecklist` : l'état garde son `useReducer` de domaine ([ADR 0002](adr/0002-useReducer-vs-redux.md)) et toute l'application le partage. Les pages et les composants le lisent par `useOnboarding()`, qui lève hors du fournisseur : `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, et les actions `hide()`, `show()`, `skip(clé)`, `resume(clé)`, `acknowledge(clé)`, `refresh()`. Le fournisseur survit à la navigation ; un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`), donc un fournisseur neuf, avec son propre chargeur. Il relit son état au montage, à chaque changement de route et après les écritures annoncées sur le canal de données. Ses chargements sont numérotés et seule la réponse du dernier lancé s'applique ; une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle ; une relecture qui échoue après un premier succès garde l'état affiché.
|
||||
|
||||
**Le canal de données.** `src/services/dataEvents.ts` ([ADR 0022](adr/0022-canal-evenements-donnees.md)) : chaque écriture qui peut changer un compteur du parcours appelle `notifyDataChanged()` une fois réussie (la liste des écritures et la règle d'ajout sont dans l'ADR), et le fournisseur, seul abonné, relit son état 150 ms après la dernière écriture d'une rafale (`followDataChanges`). C'est ce qui coche une étape pendant que sa page est ouverte.
|
||||
|
||||
|
|
@ -357,11 +359,15 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
|||
| Clé | Valeur | Écrite par |
|
||||
|---|---|---|
|
||||
| `onboarding_checklist_hidden` | `"1"` quand le parcours est masqué ; un seul état pour le bandeau et la liste. Supprimée au réaffichage | `hideChecklist` (« Masquer », et « Fermer » à la fin du parcours) et la règle D9 ; `showChecklist` la supprime |
|
||||
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des six clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
|
||||
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des huit clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
|
||||
| `onboarding_guided_initialized` | `"1"` une fois la règle D9 appliquée au profil | `initializeGuidedPath` |
|
||||
| `onboarding_acknowledged_<clé>` | `"1"` par étape de visite faite (#425), `<clé>` étant l'une des deux clés de `VISIT_STEP_KEYS` (`categories`, `reports`). Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `acknowledgeStep` (« J'ai compris ») et `initializeVisitSteps` |
|
||||
| `onboarding_visit_steps_initialized` | `"1"` une fois les étapes de visite initialisées pour le profil (#425) | `initializeVisitSteps` |
|
||||
|
||||
**La règle D9.** Au premier chargement, `createOnboardingLoader` attend `initializeGuidedPath`, une fois par fournisseur (promesse partagée contre le double montage de StrictMode). Le drapeau `onboarding_guided_initialized` est d'abord lu hors transaction : une fois posé, chaque ouverture suivante coûte cette lecture et n'ouvre aucune transaction. S'il manque, une transaction le relit, puis un profil qui a déjà des transactions reçoit `onboarding_checklist_hidden = "1"`, et le drapeau est posé. Après le `COMMIT`, et seulement si le parcours vient d'être masqué, une ligne est écrite au Journal. Un échec annule les deux écritures et le chargement continue sur l'état stocké ; le drapeau absent fait réessayer à l'ouverture suivante. Un profil vide ou neuf garde donc le parcours, et un profil déjà actif le trouve masqué, réaffichable depuis Paramètres → Utilisateurs.
|
||||
|
||||
**Le cochage d'office des étapes de visite** (#425). Une seule fois par profil, après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, le fournisseur appelle `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` (promesse partagée contre le double montage de StrictMode), puis relit. `shouldAcknowledgeVisitSteps` (pur) est vrai quand chaque ligne déverrouillée hors des étapes de visite est faite ou passée : un profil qui avait terminé le parcours les trouve faites et garde « Démarrage terminé », un profil au parcours incomplet les garde à faire. L'initialisation n'ouvre **aucune transaction** : sous le verrou strict de `db.ts` (#408), un appel de service lancé depuis le rappel d'une transaction attendrait derrière ce même verrou. Les écritures sont idempotentes, les reconnaissances d'abord, le drapeau en dernier : un arrêt entre deux refait le même calcul à l'ouverture suivante. Elle ne notifie pas le canal ([ADR 0022](adr/0022-canal-evenements-donnees.md), §5).
|
||||
|
||||
**Les modèles purs** (`src/utils/onboardingPath.ts`, sans React, routeur ni base) :
|
||||
|
||||
- `nextStep` : la première ligne déverrouillée ni faite ni passée ;
|
||||
|
|
@ -375,11 +381,11 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
|||
|
||||
`deriveOnboardingView` (`useOnboardingChecklist.ts`, pur) les compose à chaque rendu du fournisseur : étape suivante, verrou de carte et mode.
|
||||
|
||||
**Le bandeau.** `OnboardingBanner` (`src/components/onboarding/`, piloté par props) est rendu par `AppShell` au-dessus de chaque page, dans le flux de la page, avec `key={pathname}` : chaque page commence liste repliée. En mode `banner` : « Démarrage n/N », qui attend `budgetReady`, « Étape suivante » avec le lien de l'étape, « Voir la liste » / « Fermer la liste » (`aria-expanded`, `aria-controls`) et « Masquer ». En mode `complete` : le message de fin, « Ouvrir le guide » (`/settings/users#gettingStarted`) et « Fermer », qui masque le parcours. Dans les modes `none` et `card`, il ne rend rien.
|
||||
**Le bandeau.** `OnboardingBanner` (`src/components/onboarding/`, piloté par props) est rendu par `AppShell` au-dessus de chaque page, dans le flux de la page, avec `key={pathname}` : chaque page commence liste repliée. En mode `banner` : « Démarrage n/N », qui attend `budgetReady && visitStepsInitialized`, « Étape suivante » avec le lien de l'étape, « Voir la liste » / « Fermer la liste » (`aria-expanded`, `aria-controls`) et « Masquer ». En mode `complete` : le message de fin, « Ouvrir le guide » (`/settings/users#gettingStarted`) et « Fermer », qui masque le parcours. Dans les modes `none` et `card`, il ne rend rien.
|
||||
|
||||
**Le verrou de carte.** `latchCardPage(previous, { pathname, nextStep, hidden })` rend la page qui porte la carte, ou null. Il est posé quand la route courante est celle de l'étape suivante et figure dans `CARD_PAGES`. Il tient quand l'étape se coche : la carte reste pour annoncer « Fait. » et proposer la suite, même quand l'étape suivante vit sur une autre page. Il est relâché au changement de route, quand le parcours est masqué, et par « Passer » (`SKIP`) ; le rendu suivant le repose si la nouvelle étape suivante est sur la même page. La fonction est idempotente : le fournisseur la dérive pendant le rendu et range le résultat dans son reducer (`SET_CARD_PAGE`), ce qui évite l'image de bandeau qu'un effet laisserait passer sur une page à carte.
|
||||
|
||||
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « Passer cette étape » tant que l'étape n'est pas faite ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
|
||||
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « J'ai compris » avant « Passer cette étape » pour une étape de visite (`onAcknowledge`, passé par `OnboardingStepCard` quand `VISIT_STEP_KEYS` contient l'étape de la carte ; `ACKNOWLEDGE` garde le verrou de carte, qui annonce « Fait. » puis l'étape suivante), « Passer cette étape » tant que l'étape n'est pas faite ; le rang « Étape n sur N » et la fin attendent `budgetReady && visitStepsInitialized` ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
|
||||
|
||||
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, et sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, une seule cible à la fois. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,19 +20,21 @@ Simpl'Résultat vous aide à suivre vos finances personnelles en important des r
|
|||
|
||||
### Démarrage rapide
|
||||
|
||||
Les six étapes ci-dessous sont celles du parcours de démarrage. La carte d'accueil du tableau de bord d'un profil neuf les liste, puis le bandeau en haut de chaque page mène de l'une à la suivante (voir « Le parcours de démarrage », plus bas) ; chaque étape se coche d'elle-même quand elle est faite.
|
||||
Les huit étapes ci-dessous sont celles du parcours de démarrage. La carte d'accueil du tableau de bord d'un profil neuf les liste, puis le bandeau en haut de chaque page mène de l'une à la suivante (voir « Le parcours de démarrage », plus bas) ; chaque étape se coche d'elle-même quand elle est faite.
|
||||
|
||||
1. Créer un profil — chaque profil a sa propre base de données
|
||||
2. Choisir un dossier d'import — le dossier où vous déposerez vos relevés, un sous-dossier par compte bancaire
|
||||
3. Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier
|
||||
4. Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé
|
||||
5. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
|
||||
6. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
|
||||
5. Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer
|
||||
6. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
|
||||
7. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
|
||||
8. Parcourir les rapports — la période, les faits saillants et les cartes de rapports
|
||||
|
||||
### Le parcours de démarrage
|
||||
|
||||
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/6 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste
|
||||
2. « Voir la liste » déplie les six étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
|
||||
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste
|
||||
2. « Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
|
||||
3. Sur les pages Import, Transactions et Budget, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte
|
||||
4. La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel
|
||||
5. Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire
|
||||
|
|
@ -41,7 +43,7 @@ Les six étapes ci-dessous sont celles du parcours de démarrage. La carte d'acc
|
|||
8. « Masquer », dans le bandeau ou dans la liste, masque le bandeau et la liste pour ce profil seulement. Pour les retrouver, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages. Ce bouton est visible dès que le parcours est masqué
|
||||
9. Un profil qui contenait déjà des transactions avant l'arrivée du parcours guidé le voit masqué d'office à sa première ouverture, comme après « Masquer » ; Paramètres → Utilisateurs le réaffiche. Un profil vide ou neuf le garde
|
||||
|
||||
Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 5/5.
|
||||
Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. Les deux étapes de visite, « Parcourir les catégories et les mots-clés » et « Parcourir les rapports », se cochent par « J'ai compris » sur leur carte ; un profil qui avait terminé le parcours avant leur arrivée les trouve déjà faites. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 7/7.
|
||||
|
||||
### Astuces
|
||||
|
||||
|
|
@ -94,7 +96,7 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
|
|||
- Histogramme empilé des dépenses par catégorie et par mois
|
||||
- Sélecteur de période ajustable (par défaut : année à ce jour)
|
||||
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
||||
- Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : six étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »
|
||||
- Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »
|
||||
|
||||
### Comment faire
|
||||
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@
|
|||
expect: "L'installateur s'exécute sans avertissement bloquant et l'application s'ouvre sur la sélection de profil." },
|
||||
{ id: "profile", title: "Créer un profil",
|
||||
expect: "Le profil s'ouvre directement après sa création (NIP facultatif)." },
|
||||
{ id: "dashboard-empty", title: "Tableau de bord vide avec la liste de démarrage à 1 / 6",
|
||||
expect: "Une carte d'accueil remplace les graphiques, la liste montre six lignes, seule « Créer un profil » est cochée, le compteur indique 1 / 6 (ou 1 / 5 sans clé)." },
|
||||
{ id: "dashboard-empty", title: "Tableau de bord vide avec la liste de démarrage à 1 / 8",
|
||||
expect: "Une carte d'accueil remplace les graphiques, la liste montre huit lignes, seule « Créer un profil » est cochée, le compteur indique 1 / 8 (ou 1 / 7 sans clé)." },
|
||||
{ id: "cta-folder", title: "Cliquer « Choisir un dossier d'import »",
|
||||
expect: "Le bouton de la carte d'accueil mène à la page Import." },
|
||||
{ id: "import-no-folder", title: "Page Import sans dossier",
|
||||
|
|
@ -201,7 +201,7 @@
|
|||
{ id: "budget", title: "Budget (clé Base ou Premium) : saisir un montant",
|
||||
expect: "La page Budget montre la grille (pas l'état vide, puisqu'il y a des transactions) ; un montant saisi dans une cellule est conservé." },
|
||||
{ id: "hide-reshow", title: "« Masquer » sur un second profil, puis réaffichage depuis Paramètres",
|
||||
expect: "Créer un second profil (clé Base ou plus) : sa carte d'accueil montre la liste à 1 / 6. « Masquer » retire la liste de ce profil seulement, ainsi que le bandeau de ses autres pages ; le premier profil n'est pas touché. Paramètres → Utilisateurs montre « Afficher la liste de démarrage », qui ramène le parcours." },
|
||||
expect: "Créer un second profil (clé Base ou plus) : sa carte d'accueil montre la liste à 1 / 8. « Masquer » retire la liste de ce profil seulement, ainsi que le bandeau de ses autres pages ; le premier profil n'est pas touché. Paramètres → Utilisateurs montre « Afficher la liste de démarrage », qui ramène le parcours." },
|
||||
{ id: "guide-link", title: "Ouvrir le guide « Exporter un CSV depuis votre banque » depuis l'état vide d'Import",
|
||||
expect: "Sur un profil sans dossier ou sans source, le lien de l'état vide ouvre le guide directement sur la sous-section, avec les procédures par banque." }
|
||||
]
|
||||
|
|
@ -209,26 +209,26 @@
|
|||
{
|
||||
id: "parcours-guide",
|
||||
title: "Parcours guidé : un profil neuf de bout en bout",
|
||||
intro: "Phase 2 (#396 à #401), sur un profil neuf en édition Base ou Premium. De l'étape 2 à l'étape 6, avance seulement par les liens d'étape (ligne de la liste, « Étape suivante »), sans revenir au tableau de bord : chaque page doit dire quoi faire.",
|
||||
intro: "Phase 2 (#396 à #401), sur un profil neuf en édition Base ou Premium. De l'étape 2 à l'étape 8, avance seulement par les liens d'étape (ligne de la liste, « Étape suivante »), sans revenir au tableau de bord : chaque page doit dire quoi faire.",
|
||||
steps: [
|
||||
{ id: "guided-dashboard", title: "Tableau de bord vide sans bandeau, autres pages avec bandeau",
|
||||
expect: "Sur le tableau de bord, aucun bandeau de démarrage au-dessus du titre : la carte d'accueil porte déjà la liste. Ouvre Catégories, puis Rapports : chacune commence par le bandeau « Démarrage 1/6 · Étape suivante : Choisir un dossier d'import → », avec « Voir la liste » et « Masquer »." },
|
||||
expect: "Sur le tableau de bord, aucun bandeau de démarrage au-dessus du titre : la carte d'accueil porte déjà la liste. Ouvre Catégories, puis Rapports : chacune commence par le bandeau « Démarrage 1/8 · Étape suivante : Choisir un dossier d'import → », avec « Voir la liste » et « Masquer »." },
|
||||
{ id: "guided-import-card", title: "Étape 2 : la carte Import, son contour et le focus",
|
||||
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 6 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
|
||||
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 8 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
|
||||
{ id: "guided-import-folder", title: "Étape 3 : dossier choisi, puis dossier d'exemple",
|
||||
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 6 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte d'un compte et entoure la première source." },
|
||||
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 8 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte d'un compte et entoure la première source." },
|
||||
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
|
||||
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → » ; après « Terminé », elle garde « Fait. »." },
|
||||
{ id: "guided-transactions", title: "Étape 5 : Transactions, entrée guidée",
|
||||
expect: "Clique « Étape suivante » sur la carte Import. La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 5 sur 6 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
|
||||
{ id: "guided-budget", title: "Étape 6 : Budget, « Tout déplier » puis la cellule du mois",
|
||||
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 6 sur 6 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. », puis le message de fin, avec « Ouvrir le guide » et « Fermer »." },
|
||||
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Parcourir les catégories et les mots-clés → » ; après « Terminé », elle garde « Fait. »." },
|
||||
{ id: "guided-transactions", title: "Étape 6 : Transactions, entrée guidée",
|
||||
expect: "Clique « Étape suivante » sur la carte Catégories, après « J'ai compris » (étape 5, #432). La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 6 sur 8 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
|
||||
{ id: "guided-budget", title: "Étape 7 : Budget, « Tout déplier » puis la cellule du mois",
|
||||
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 7 sur 8 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. » et propose « Étape suivante : Parcourir les rapports → »." },
|
||||
{ id: "guided-budget-tab", title: "Budget : Tab enregistre et passe au mois suivant",
|
||||
expect: "Dans une cellule, saisis un montant puis Tab : le montant est enregistré et la saisie passe au mois suivant, comme le dit la carte." },
|
||||
{ id: "guided-live", title: "Les étapes se cochent page ouverte",
|
||||
expect: "Pendant les étapes 2 à 6, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
|
||||
expect: "Pendant les étapes 2 à 8, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, chaque « J'ai compris », le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
|
||||
{ id: "guided-end", title: "Fin du parcours : bandeau de fin, « Ouvrir le guide » et « Fermer »",
|
||||
expect: "Quitte la page Budget : en haut de chaque page, tableau de bord compris, le bandeau dit « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. ». « Ouvrir le guide » ouvre Paramètres → Utilisateurs sur la section « Premiers pas », qui décrit le parcours (« Le parcours de démarrage »). « Fermer » retire le bandeau de toutes les pages, et Paramètres → Utilisateurs propose alors « Afficher la liste de démarrage »." }
|
||||
expect: "Après « J'ai compris » sur la carte Rapports (étape 8, #432), quitte la page : en haut de chaque page, tableau de bord compris, le bandeau dit « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. ». « Ouvrir le guide » ouvre Paramètres → Utilisateurs sur la section « Premiers pas », qui décrit le parcours (« Le parcours de démarrage »). « Fermer » retire le bandeau de toutes les pages, et Paramètres → Utilisateurs propose alors « Afficher la liste de démarrage »." }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -237,7 +237,7 @@
|
|||
intro: "Sur un profil où le parcours n'est pas terminé, par exemple un second profil neuf (clé Base ou plus).",
|
||||
steps: [
|
||||
{ id: "guided-list", title: "« Voir la liste » et « Fermer la liste »",
|
||||
expect: "Sur une page sans carte (Catégories, par exemple), « Voir la liste » déplie les six étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
|
||||
expect: "Sur une page sans carte (Catégories, par exemple), « Voir la liste » déplie les huit étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
|
||||
{ id: "guided-skip-resume", title: "« Passer cette étape », puis « Reprendre »",
|
||||
expect: "Sur la carte Transactions ou Budget, clique « Passer cette étape » : la carte laisse la place au bandeau (ou au message de fin si c'était la dernière étape) et le compteur avance d'une étape. Sur la page Import, passer une étape fait passer la carte à l'étape suivante de la page. Dans la liste dépliée, l'étape passée montre l'icône « passer », le badge « Passée » et « Reprendre ». Depuis une autre page, « Reprendre » rouvre la page de l'étape avec sa carte, le focus sur son titre, et l'étape redevient à faire." },
|
||||
{ id: "guided-hide-users", title: "« Masquer » depuis Paramètres → Utilisateurs",
|
||||
|
|
@ -251,24 +251,24 @@
|
|||
steps: [
|
||||
{ id: "guided-d9", title: "Profil déjà actif masqué d'office, puis réaffiché (règle D9)",
|
||||
expect: "Ouvre un profil qui avait des transactions avant cette version. Aucun bandeau ni carte, sur aucune page ; le Journal (Paramètres → Système) porte « Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again) ». Paramètres → Utilisateurs propose « Afficher la liste de démarrage » : un clic ramène le bandeau, avec un compteur fidèle aux données du profil. Rouvrir le profil ne le masque plus." },
|
||||
{ id: "guided-free", title: "Édition Gratuite : 5 étapes, fin à 5/5",
|
||||
expect: "Sur une session ou une machine sans clé, avec un profil neuf : le compteur dit 1/5, et la ligne Budget est verrouillée « Base et plus » et mène à la carte Licence. Après le classement d'une transaction, la carte Transactions (« Étape 5 sur 5 ») annonce « Fait. » puis le message de fin : aucune carte Budget, fin à 5/5." },
|
||||
{ id: "guided-free", title: "Édition Gratuite : 7 étapes, fin à 7/7",
|
||||
expect: "Sur une session ou une machine sans clé, avec un profil neuf : le compteur dit 1/7, et la ligne Budget est verrouillée « Base et plus » et mène à la carte Licence. Après le classement d'une transaction, la carte Transactions (« Étape 6 sur 7 ») annonce « Fait. » puis « Étape suivante : Parcourir les rapports → » : aucune carte Budget ; après « J'ai compris » sur la carte Rapports, fin à 7/7." },
|
||||
{ id: "guided-noop-import", title: "Un import qui n'ajoute rien",
|
||||
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». Après « Terminé », la carte reste sur « Étape 4 sur 6 » (« 4 sur 5 » en édition Gratuite), entoure la première source et n'annonce toujours pas « Fait. » ; la liste laisse « Importer un premier fichier » à faire." },
|
||||
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». Après « Terminé », la carte reste sur « Étape 4 sur 8 » (« 4 sur 7 » en édition Gratuite), entoure la première source et n'annonce toujours pas « Fait. » ; la liste laisse « Importer un premier fichier » à faire." },
|
||||
{ id: "guided-skip-folder", title: "Limite connue : « Passer » sur l'étape du dossier, sans dossier",
|
||||
expect: "Sur un profil neuf, sur la carte Import « Étape 2 sur 6 · Choisir un dossier d'import », clique « Passer cette étape » sans choisir de dossier. La carte passe à « Étape 3 sur 6 · Configurer une source d'import », mais garde la phrase et le contour du choix du dossier ; un second « Passer » mène à l'étape 4, avec la même phrase, et le bandeau revient au troisième. À juger : les étapes de la page dépendent du dossier." },
|
||||
expect: "Sur un profil neuf, sur la carte Import « Étape 2 sur 8 · Choisir un dossier d'import », clique « Passer cette étape » sans choisir de dossier. La carte passe à « Étape 3 sur 8 · Configurer une source d'import », mais garde la phrase et le contour du choix du dossier ; un second « Passer » mène à l'étape 4, avec la même phrase, et le bandeau revient au troisième. À juger : les étapes de la page dépendent du dossier." },
|
||||
{ id: "guided-empty-profile", title: "Étapes d'import passées : Transactions et Budget sans transaction",
|
||||
expect: "Sur un profil neuf en édition Base ou plus, sur un build de production (le mode dev masque le cas Budget), choisis un dossier d'import vide, puis passe les étapes 3, 4 et 5 avec « Passer cette étape ». Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » au-dessus de l'état vide « Aucune transaction pour l'instant », que la barre de filtres et un bref chargement peuvent précéder (limite connue, à juger). Sur la page Budget, la carte « Étape 6 sur 6 · Saisir un premier budget » garde son titre et « Passer cette étape », sans phrase ni contour, au-dessus de l'état vide « Le budget se construit sur vos transactions » ; elle ne dit jamais « Cliquez « Tout déplier »… »." },
|
||||
expect: "Sur un profil neuf en édition Base ou plus, sur un build de production (le mode dev masque le cas Budget), choisis un dossier d'import vide, puis passe les étapes 3, 4 et 5 avec « Passer cette étape » (l'étape 5 sur la carte Catégories, #432). Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » au-dessus de l'état vide « Aucune transaction pour l'instant », que la barre de filtres et un bref chargement peuvent précéder (limite connue, à juger). Passe l'étape 6 depuis cette carte. Sur la page Budget, la carte « Étape 7 sur 8 · Saisir un premier budget » garde son titre et « Passer cette étape », sans phrase ni contour, au-dessus de l'état vide « Le budget se construit sur vos transactions » ; elle ne dit jamais « Cliquez « Tout déplier »… »." },
|
||||
{ id: "guided-fallback", title: "Transactions : aucune transaction à classer",
|
||||
expect: "Sur un profil de test où l'étape 5 reste à faire, rends toutes les transactions catégorisées sans les classer à la main : ajoute, depuis la page Catégories, un mot-clé pour chaque transaction restée sans catégorie, puis clique « Auto-catégoriser » sur la page Transactions. Depuis une autre page, suis « Étape suivante : Classer une transaction à la main → ». La page montre toutes les transactions, période « Tout », une seule fois, et la carte dit « Faites un clic droit sur une transaction, puis « Ajouter comme mot-clé »… ». « Aucune transaction trouvée. » peut s'afficher un très bref instant entre les deux lectures (limite connue)." },
|
||||
expect: "Sur un profil de test où l'étape 6 reste à faire, rends toutes les transactions catégorisées sans les classer à la main : ajoute, depuis la page Catégories, un mot-clé pour chaque transaction restée sans catégorie, puis clique « Auto-catégoriser » sur la page Transactions. Depuis une autre page, suis « Étape suivante : Classer une transaction à la main → ». La page montre toutes les transactions, période « Tout », une seule fois, et la carte dit « Faites un clic droit sur une transaction, puis « Ajouter comme mot-clé »… ». « Aucune transaction trouvée. » peut s'afficher un très bref instant entre les deux lectures (limite connue)." },
|
||||
{ id: "guided-same-page", title: "Limite connue : un lien d'étape suivi depuis la page Transactions",
|
||||
expect: "Passe l'étape 5 depuis sa carte, change les filtres de la page Transactions, puis, sur cette même page, clique « Reprendre » dans la liste du bandeau. La carte revient, mais la page garde tes filtres au lieu de l'entrée guidée ; la phrase de la carte suit le filtre réellement affiché. À juger." },
|
||||
expect: "Passe l'étape 6 depuis sa carte, change les filtres de la page Transactions, puis, sur cette même page, clique « Reprendre » dans la liste du bandeau. La carte revient, mais la page garde tes filtres au lieu de l'entrée guidée ; la phrase de la carte suit le filtre réellement affiché. À juger." },
|
||||
{ id: "guided-budget-expanded", title: "Budget : grille déjà dépliée par le profil",
|
||||
expect: "Sur un profil où l'étape 6 reste à faire, déplie la grille (« Tout déplier ») sans saisir de montant, puis quitte la page Budget. Reviens par le lien de l'étape 6 : la grille est restée dépliée et la carte entoure directement la cellule du mois courant." },
|
||||
expect: "Sur un profil où l'étape 7 reste à faire, déplie la grille (« Tout déplier ») sans saisir de montant, puis quitte la page Budget. Reviens par le lien de l'étape 7 : la grille est restée dépliée et la carte entoure directement la cellule du mois courant." },
|
||||
{ id: "guided-budget-offscreen", title: "Limite connue : cellule du mois hors de la zone visible",
|
||||
expect: "En fenêtre étroite, la grille du budget défile horizontalement et la cellule du mois courant peut être hors de vue (les derniers mois de l'année sont à droite) : rien ne défile tout seul. À juger : la phrase et le contour suffisent-ils pour la trouver ?" },
|
||||
{ id: "guided-budget-zero", title: "Budget : une cellule quittée vide enregistre 0 $",
|
||||
expect: "Sur un profil où l'étape 6 reste à faire, clique une cellule puis quitte-la sans rien saisir : 0 $ est enregistré et la carte annonce « Fait. ». C'est le comportement existant de la grille ; à juger." },
|
||||
expect: "Sur un profil où l'étape 7 reste à faire, clique une cellule puis quitte-la sans rien saisir : 0 $ est enregistré et la carte annonce « Fait. ». C'est le comportement existant de la grille ; à juger." },
|
||||
{ id: "guided-import-flash", title: "Limite connue : « Aucun dossier configuré » à l'ouverture d'Import",
|
||||
expect: "À l'ouverture de la page Import, la carte du dossier peut afficher un très bref instant « Aucun dossier configuré » avant la lecture du dossier ; l'état vide et la carte de démarrage, eux, attendent cette lecture. À juger." }
|
||||
]
|
||||
|
|
@ -297,11 +297,21 @@
|
|||
{ id: "v0171-import-source-reopen", title: "Import : la source est retrouvée au prochain import (#426)",
|
||||
expect: "Importe un fichier d'une source, puis « Terminé » et rouvre la même source : aucun bandeau de détection (le format enregistré est restauré tel quel), le fichier déjà importé porte le badge « Importé » et n'est pas coché. Sur une base créée avant ce correctif, une source déjà renommée garde ce défaut (hors périmètre)." },
|
||||
{ id: "v0171-transactions-card-keyword", title: "Transactions : la carte propose « Ajouter comme mot-clé » (#430)",
|
||||
expect: "Par le lien de l'étape 5, la carte dit : « Seules les transactions non catégorisées sont affichées. Faites un clic droit sur l'une d'elles, puis « Ajouter comme mot-clé » : le mot-clé classe cette transaction et les transactions semblables de l'aperçu, et servira aux prochains imports. Gardez un mot propre au commerçant, pas « PAIEMENT », et décochez les transactions déjà bien classées. Vous pouvez aussi cliquer sur sa catégorie et en choisir une. » Avec le filtre « Toutes », la phrase commence par « Faites un clic droit sur une transaction… » et garde la même consigne. Le contour reste sur la catégorie de la première ligne. En anglais, “Add as keyword” et “PAYMENT” sont entre “ ”." },
|
||||
expect: "Par le lien de l'étape 6, la carte dit : « Seules les transactions non catégorisées sont affichées. Faites un clic droit sur l'une d'elles, puis « Ajouter comme mot-clé » : le mot-clé classe cette transaction et les transactions semblables de l'aperçu, et servira aux prochains imports. Gardez un mot propre au commerçant, pas « PAIEMENT », et décochez les transactions déjà bien classées. Vous pouvez aussi cliquer sur sa catégorie et en choisir une. » Avec le filtre « Toutes », la phrase commence par « Faites un clic droit sur une transaction… » et garde la même consigne. Le contour reste sur la catégorie de la première ligne. En anglais, “Add as keyword” et “PAYMENT” sont entre “ ”." },
|
||||
{ id: "v0171-transactions-keyword-checks", title: "Transactions : la boîte du mot-clé coche l'étape (#430)",
|
||||
expect: "Sur un profil où l'étape 5 reste à faire, suis la carte : clic droit sur une transaction, « Ajouter comme mot-clé », remplace le mot prérempli par un mot propre au commerçant si besoin, choisis une catégorie, décoche une transaction de l'aperçu, puis « Appliquer et recatégoriser ». La carte annonce « Fait. » sans quitter la page ; la transaction décochée garde sa catégorie. La boîte elle-même n'a pas changé." },
|
||||
expect: "Sur un profil où l'étape 6 reste à faire, suis la carte : clic droit sur une transaction, « Ajouter comme mot-clé », remplace le mot prérempli par un mot propre au commerçant si besoin, choisis une catégorie, décoche une transaction de l'aperçu, puis « Appliquer et recatégoriser ». La carte annonce « Fait. » sans quitter la page ; la transaction décochée garde sa catégorie. La boîte elle-même n'a pas changé." },
|
||||
{ id: "v0171-budget-card-leaves", title: "Budget : la carte nomme les feuilles et la colonne « Annuel » (#430)",
|
||||
expect: "Grille repliée, la carte dit : « Cliquez « Tout déplier » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « Annuel » est réparti également sur les 12 mois et remplace les montants mensuels. » Après « Tout déplier », la phrase commence par « Seules les catégories du dernier niveau… » et garde la phrase sur « Annuel ». Vérifie dans la grille que l'affirmation tient : une ligne à chevron n'accepte pas de montant ; 120 $ dans « Annuel » donne 10 $ par mois et remplace les montants déjà saisis. En anglais, “Expand all” et “Annual” sont entre “ ”." }
|
||||
expect: "Grille repliée, la carte dit : « Cliquez « Tout déplier » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « Annuel » est réparti également sur les 12 mois et remplace les montants mensuels. » Après « Tout déplier », la phrase commence par « Seules les catégories du dernier niveau… » et garde la phrase sur « Annuel ». Vérifie dans la grille que l'affirmation tient : une ligne à chevron n'accepte pas de montant ; 120 $ dans « Annuel » donne 10 $ par mois et remplace les montants déjà saisis. En anglais, “Expand all” et “Annual” sont entre “ ”." },
|
||||
{ id: "v0171-visit-eight-rows", title: "Liste de démarrage : huit lignes, deux étapes de visite (#425)",
|
||||
expect: "Sur un profil neuf, la liste montre dans l'ordre : « Créer un profil », « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget », « Parcourir les rapports ». Le compteur dit 1 / 8 (1 / 7 sans clé, la ligne Budget verrouillée « Base et plus »). Après l'import, « Étape suivante » mène à « Parcourir les catégories et les mots-clés ». Ouvrir Catégories ou Rapports sans cliquer ne coche rien. En anglais : “Browse categories and keywords”, “Browse the reports”." },
|
||||
{ id: "v0171-visit-got-it", title: "« J'ai compris » coche une étape de visite (#425, #432)",
|
||||
expect: "Sur la carte d'une étape de visite (pages Catégories et Rapports), « J'ai compris » s'affiche avant « Passer cette étape ». Un clic coche l'étape : la carte reste, annonce « Fait. » et propose l'étape suivante (ou le message de fin après Rapports) ; dans la liste, la ligne est barrée, sans le badge « Passée ». « Passer cette étape » donne, lui, une ligne « Passée » avec « Reprendre ». En anglais : “Got it”." },
|
||||
{ id: "v0171-visit-auto-done", title: "Profil au parcours fini : les étapes de visite sont faites d'office (#425)",
|
||||
expect: "Ouvre, avec cette version, un profil dont les six anciennes étapes étaient faites ou passées et dont le parcours n'est pas masqué (il affichait « Démarrage terminé »). À aucun moment le bandeau ne passe par « Étape suivante : Parcourir les catégories et les mots-clés » : il garde « Démarrage terminé ». Dans la liste, les deux étapes de visite sont barrées. Le Journal (Paramètres → Système) porte « Onboarding: the path was already complete, visit steps marked done ». Rouvrir le profil ne change plus rien." },
|
||||
{ id: "v0171-visit-incomplete", title: "Profil au parcours incomplet : les étapes de visite restent à faire (#425)",
|
||||
expect: "Ouvre, avec cette version, un profil dont au moins une des six anciennes étapes restait à faire. Les deux étapes de visite sont à faire, et le restent une fois les six autres terminées : le compteur dit 6/8, et « Étape suivante » mène à la première étape de visite non faite." },
|
||||
{ id: "v0171-visit-pending", title: "Limite connue : lignes de visite « en attente » avant l'initialisation (#425)",
|
||||
expect: "À la toute première ouverture d'un profil avec cette version, le temps de lire la licence et d'initialiser les étapes de visite (un instant), la liste peut montrer les deux étapes de visite « en attente » (cercle pâle, lu « en attente » par un lecteur d'écran), jamais avec le badge « Base et plus », et sans compteur. Elles passent ensuite à faire ou faites. À juger si l'instant est visible." }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -318,7 +328,7 @@
|
|||
{ id: "guided-contrast", title: "Contraste du contour, thème clair et thème sombre",
|
||||
expect: "Dans les deux thèmes (bouton de la barre latérale), le contour se distingue nettement du fond de la page, des cartes et du contour de focus du clavier, sur les pages Import, Transactions et Budget." },
|
||||
{ id: "guided-keyboard", title: "Clavier seul",
|
||||
expect: "Sans souris, de l'étape 2 à l'étape 6 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Quand le contrôle entouré a le focus, l'anneau de focus du système remplace le contour. Le focus reste visible partout et n'est jamais pris au piège." },
|
||||
expect: "Sans souris, de l'étape 2 à l'étape 8 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « J'ai compris », « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Quand le contrôle entouré a le focus, l'anneau de focus du système remplace le contour. Le focus reste visible partout et n'est jamais pris au piège." },
|
||||
{ id: "guided-focus-after-hide", title: "Limite connue : le focus après « Masquer » et « Fermer »",
|
||||
expect: "Au clavier, active « Masquer » dans le bandeau, puis, sur un autre profil, « Fermer » au message de fin : le bouton disparaît avec le bandeau et le focus retombe sur la page elle-même, comme avec l'ancienne barre compacte ; note d'où Tab repart. Même chose après un lien du bandeau vers une page sans carte (« Ouvrir le guide », ligne Budget verrouillée). À juger, avec Orca puis NVDA : le lecteur d'écran perd-il le fil ?" },
|
||||
{ id: "guided-orca", title: "Annonces d'Orca (Linux)",
|
||||
|
|
|
|||
|
|
@ -15,11 +15,18 @@ import { computeChecklist } from "../../utils/onboardingChecklist";
|
|||
|
||||
const checklist = computeChecklist(
|
||||
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
||||
{ importFolderSet: false, budgetAllowed: true },
|
||||
{ importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true },
|
||||
);
|
||||
|
||||
function cardProps(hidden = false) {
|
||||
return { checklist, budgetReady: true, hidden, onHide: vi.fn(), onResume: vi.fn() };
|
||||
return {
|
||||
checklist,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
hidden,
|
||||
onHide: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const markup = (hidden = false) =>
|
||||
|
|
@ -36,7 +43,7 @@ describe("DashboardEmptyState (#352, #397)", () => {
|
|||
expect(html).toContain("t(dashboard.empty.description)");
|
||||
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
|
||||
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
|
||||
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=6])");
|
||||
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=8])");
|
||||
});
|
||||
|
||||
it("keeps the card and drops the list once the path is hidden", () => {
|
||||
|
|
@ -52,6 +59,13 @@ describe("DashboardEmptyState (#352, #397)", () => {
|
|||
expect(list.type).toBe(OnboardingChecklist);
|
||||
expect(list.props.items).toBe(checklist.items);
|
||||
expect(list.props.budgetReady).toBe(true);
|
||||
expect(list.props.visitStepsInitialized).toBe(true);
|
||||
const waiting = DashboardEmptyState({ ...props, visitStepsInitialized: false }) as ReactElement<{
|
||||
children?: ReactNode;
|
||||
}>;
|
||||
expect(
|
||||
(waiting.props.children as ReactElement<Record<string, unknown>>).props.visitStepsInitialized,
|
||||
).toBe(false);
|
||||
expect(list.props.onHide).toBe(props.onHide);
|
||||
expect(list.props.onResume).toBe(props.onResume);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import type { ChecklistItemKey } from "../../shared/types";
|
|||
interface DashboardEmptyStateProps {
|
||||
checklist: Checklist;
|
||||
budgetReady: boolean;
|
||||
/** The visit steps are initialized (#425): their rows stay "pending" until then. */
|
||||
visitStepsInitialized: boolean;
|
||||
/** The user dismissed the checklist: the welcome card stays, the list goes. */
|
||||
hidden: boolean;
|
||||
onHide: () => void;
|
||||
|
|
@ -24,6 +26,7 @@ interface DashboardEmptyStateProps {
|
|||
export default function DashboardEmptyState({
|
||||
checklist,
|
||||
budgetReady,
|
||||
visitStepsInitialized,
|
||||
hidden,
|
||||
onHide,
|
||||
onResume,
|
||||
|
|
@ -43,6 +46,7 @@ export default function DashboardEmptyState({
|
|||
done={checklist.done}
|
||||
total={checklist.total}
|
||||
budgetReady={budgetReady}
|
||||
visitStepsInitialized={visitStepsInitialized}
|
||||
onHide={onHide}
|
||||
onResume={onResume}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -67,8 +67,14 @@ function render(
|
|||
counters = ZERO,
|
||||
budgetAllowed = true,
|
||||
skipped: ChecklistItemKey[] = [],
|
||||
visitStepsInitialized = true,
|
||||
) {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped });
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: false,
|
||||
budgetAllowed,
|
||||
skipped,
|
||||
visitStepsInitialized,
|
||||
});
|
||||
const onHide = vi.fn();
|
||||
const onResume = vi.fn();
|
||||
const props: OnboardingChecklistProps = {
|
||||
|
|
@ -76,6 +82,7 @@ function render(
|
|||
done: checklist.done,
|
||||
total: checklist.total,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized,
|
||||
onHide,
|
||||
onResume,
|
||||
...overrides,
|
||||
|
|
@ -97,12 +104,12 @@ function stateTextsOf(row: AnyElement): string[] {
|
|||
}
|
||||
|
||||
describe("OnboardingChecklist", () => {
|
||||
it("renders the title, the counter 1 / 6 and the hide button", () => {
|
||||
it("renders the title, the counter 1 / 8 and the hide button", () => {
|
||||
const { element, onHide } = render();
|
||||
const text = texts(element);
|
||||
expect(text).toContain("t(onboarding.checklist.title)");
|
||||
expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)");
|
||||
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=6])");
|
||||
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=8])");
|
||||
const [hide] = find(element, (e) => e.type === "button");
|
||||
expect(hide.props.type).toBe("button");
|
||||
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
|
||||
|
|
@ -110,11 +117,22 @@ describe("OnboardingChecklist", () => {
|
|||
expect(onHide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders six rows with their i18n labels", () => {
|
||||
it("renders eight rows, in order, with their i18n labels (#425)", () => {
|
||||
const { element } = render();
|
||||
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
|
||||
const rows = find(element, (e) => e.type === "li");
|
||||
const order = [
|
||||
"profile",
|
||||
"importFolder",
|
||||
"source",
|
||||
"import",
|
||||
"categories",
|
||||
"manualCategory",
|
||||
"budget",
|
||||
"reports",
|
||||
];
|
||||
expect(rows.map((r) => r.key)).toEqual(order);
|
||||
const text = texts(element);
|
||||
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
|
||||
for (const key of order) {
|
||||
expect(text).toContain(`t(onboarding.checklist.items.${key})`);
|
||||
}
|
||||
});
|
||||
|
|
@ -154,7 +172,7 @@ describe("OnboardingChecklist", () => {
|
|||
expect(link.props.to).toBe("/settings/users");
|
||||
// The license card carries no step card: nothing to focus there.
|
||||
expect(link.props.state).toBeUndefined();
|
||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=5])");
|
||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=7])");
|
||||
});
|
||||
|
||||
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
|
||||
|
|
@ -209,7 +227,7 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
|
|||
);
|
||||
// The hidden "(skipped)" text already says it: the badge is not read twice.
|
||||
expect(badge.props["aria-hidden"]).toBe("true");
|
||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=6])");
|
||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
|
||||
});
|
||||
|
||||
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
|
||||
|
|
@ -250,3 +268,51 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
|
|||
expect(link.props.to).toBe("/settings/users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingChecklist — visit steps (#425)", () => {
|
||||
it("a visit row to do links to its page with the step's navigation state", () => {
|
||||
const { element } = render();
|
||||
const [categories] = find(rowOf(element, "categories"), (e) => e.type === Link);
|
||||
expect(categories.props.to).toBe("/categories");
|
||||
expect(categories.props.state).toEqual({ onboarding: { focus: true } });
|
||||
const [reports] = find(rowOf(element, "reports"), (e) => e.type === Link);
|
||||
expect(reports.props.to).toBe("/reports");
|
||||
});
|
||||
|
||||
it("before the initialization, both visit rows are pending, without the edition badge, and the counter waits", () => {
|
||||
const { element } = render({}, ZERO, false, [], false);
|
||||
for (const key of ["categories", "reports"]) {
|
||||
const row = rowOf(element, key);
|
||||
expect(find(row, (e) => e.type === Lock), key).toHaveLength(0);
|
||||
expect(find(row, (e) => e.type === Circle), key).toHaveLength(1);
|
||||
expect(find(row, (e) => e.type === Link), key).toHaveLength(0);
|
||||
expect(texts(row), key).not.toContain("t(onboarding.checklist.lockedBudget)");
|
||||
expect(stateTextsOf(row), key).toEqual(["t(onboarding.state.pending)"]);
|
||||
}
|
||||
// The Budget row keeps its own rule: entitlement known, Free → locked.
|
||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
|
||||
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
|
||||
});
|
||||
|
||||
it("the Budget row's pending state follows budgetReady alone", () => {
|
||||
const { element } = render({ budgetReady: false }, ZERO, true, [], true);
|
||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
|
||||
expect(stateTextsOf(rowOf(element, "categories"))).toEqual([]);
|
||||
const initializedOnly = render({ budgetReady: true }, ZERO, true, [], false).element;
|
||||
expect(stateTextsOf(rowOf(initializedOnly, "budget"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("an acknowledged visit row shows as done", () => {
|
||||
const checklist = computeChecklist(ZERO, {
|
||||
importFolderSet: false,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["reports"],
|
||||
});
|
||||
const { element } = render({ items: checklist.items, done: checklist.done, total: checklist.total });
|
||||
const reports = rowOf(element, "reports");
|
||||
expect(find(reports, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||
expect(find(reports, (e) => e.type === Link)).toHaveLength(0);
|
||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { useTranslation } from "react-i18next";
|
|||
import { Link } from "react-router-dom";
|
||||
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
|
||||
import { stepNavState } from "../../utils/onboardingPath";
|
||||
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
|
||||
import { VISIT_STEP_KEYS, type ChecklistItem, type ChecklistItemKey } from "../../shared/types";
|
||||
|
||||
export interface OnboardingChecklistProps {
|
||||
items: ChecklistItem[];
|
||||
/** Number of unlocked rows that are done or skipped. */
|
||||
done: number;
|
||||
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
||||
/** Number of unlocked rows (8, or 7 when Budget is locked). */
|
||||
total: number;
|
||||
/**
|
||||
* False until the Budget entitlement is known. The budget row is then
|
||||
|
|
@ -16,6 +16,12 @@ export interface OnboardingChecklistProps {
|
|||
* `total` depends on the lock.
|
||||
*/
|
||||
budgetReady: boolean;
|
||||
/**
|
||||
* False until the profile's visit steps are initialized (#425). Their two
|
||||
* rows are then neutral ("pending", never "locked", which would show the
|
||||
* Base edition badge) and the counter is hidden, `total` depending on them.
|
||||
*/
|
||||
visitStepsInitialized: boolean;
|
||||
onHide: () => void;
|
||||
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
|
||||
onResume: (key: ChecklistItemKey) => void;
|
||||
|
|
@ -28,8 +34,13 @@ type RowState = "done" | "skipped" | "todo" | "locked" | "pending";
|
|||
* done shows as done even if it was skipped too: "Resume" would bring back
|
||||
* nothing to do.
|
||||
*/
|
||||
function rowState(item: ChecklistItem, budgetReady: boolean): RowState {
|
||||
function rowState(
|
||||
item: ChecklistItem,
|
||||
budgetReady: boolean,
|
||||
visitStepsInitialized: boolean,
|
||||
): RowState {
|
||||
if (item.key === "budget" && !budgetReady) return "pending";
|
||||
if (VISIT_STEP_KEYS.includes(item.key) && !visitStepsInitialized) return "pending";
|
||||
if (item.locked) return "locked";
|
||||
if (item.done) return "done";
|
||||
return item.skipped ? "skipped" : "todo";
|
||||
|
|
@ -75,6 +86,7 @@ export default function OnboardingChecklist({
|
|||
done,
|
||||
total,
|
||||
budgetReady,
|
||||
visitStepsInitialized,
|
||||
onHide,
|
||||
onResume,
|
||||
}: OnboardingChecklistProps) {
|
||||
|
|
@ -88,7 +100,7 @@ export default function OnboardingChecklist({
|
|||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
|
||||
{budgetReady && (
|
||||
{budgetReady && visitStepsInitialized && (
|
||||
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
|
||||
{t("onboarding.checklist.counter", { done, total })}
|
||||
</span>
|
||||
|
|
@ -105,7 +117,7 @@ export default function OnboardingChecklist({
|
|||
</div>
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const state = rowState(item, budgetReady);
|
||||
const state = rowState(item, budgetReady, visitStepsInitialized);
|
||||
const label = t(`onboarding.checklist.items.${item.key}`);
|
||||
const stateText =
|
||||
state === "todo" ? null : (
|
||||
|
|
|
|||
|
|
@ -39,12 +39,19 @@ const NEW_PROFILE: OnboardingSnapshot = {
|
|||
importFolderSet: false,
|
||||
hidden: false,
|
||||
skipped: [],
|
||||
acknowledged: [],
|
||||
visitStepsInitialized: true,
|
||||
};
|
||||
/** Folder, source and import done: the next step is the manual category, on /transactions. */
|
||||
/**
|
||||
* Folder, source and import done: the next step is the manual category, on
|
||||
* /transactions. Stopped after the import, so the visit steps are
|
||||
* acknowledged (rule of the templates, #425).
|
||||
*/
|
||||
const IMPORTED: OnboardingSnapshot = {
|
||||
...NEW_PROFILE,
|
||||
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
|
||||
importFolderSet: true,
|
||||
acknowledged: ["categories", "reports"],
|
||||
};
|
||||
/** Everything but the budget: the next step is on /budget. */
|
||||
const BUDGET_LEFT: OnboardingSnapshot = {
|
||||
|
|
@ -75,6 +82,8 @@ function pathAt(
|
|||
importFolderSet: state.importFolderSet,
|
||||
budgetAllowed,
|
||||
skipped: state.skipped,
|
||||
acknowledged: state.acknowledged,
|
||||
visitStepsInitialized: state.visitStepsInitialized,
|
||||
})
|
||||
: null;
|
||||
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
|
||||
|
|
@ -84,6 +93,7 @@ function pathAt(
|
|||
counters: state.counters,
|
||||
hidden: state.hidden,
|
||||
budgetReady,
|
||||
visitStepsInitialized: state.visitStepsInitialized,
|
||||
nextStep,
|
||||
mode,
|
||||
cardPage,
|
||||
|
|
@ -91,6 +101,7 @@ function pathAt(
|
|||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
acknowledge: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
|
@ -182,9 +193,9 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
|||
expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"');
|
||||
});
|
||||
|
||||
it("in the Free edition, /budget never gets a card and the path ends at 5/5 (#400)", () => {
|
||||
it("in the Free edition, /budget never gets a card and the path ends at 7/7 (#400)", () => {
|
||||
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
|
||||
expect(value.checklist?.total).toBe(5);
|
||||
expect(value.checklist?.total).toBe(7);
|
||||
expect(value.nextStep).toBeNull();
|
||||
expect(value.cardPage).toBeNull();
|
||||
expect(value.mode).toBe("complete");
|
||||
|
|
@ -209,10 +220,19 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
|||
expect(waiting).toContain(BANNER);
|
||||
expect(waiting).not.toContain("onboarding.banner.progress");
|
||||
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
|
||||
"t(onboarding.banner.progress[done=4,total=6])",
|
||||
"t(onboarding.banner.progress[done=6,total=8])",
|
||||
);
|
||||
});
|
||||
|
||||
it("before the visit steps' initialization: no counter, and no end for a path that was complete (#425)", () => {
|
||||
const waiting = renderAt("/reports", loaded({ ...IMPORTED, acknowledged: [], visitStepsInitialized: false }));
|
||||
expect(waiting).toContain(BANNER);
|
||||
expect(waiting).not.toContain("onboarding.banner.progress");
|
||||
const complete = renderAt("/reports", loaded({ ...ALL_DONE, acknowledged: [], visitStepsInitialized: false }));
|
||||
expect(complete).not.toContain(BANNER);
|
||||
expect(complete).not.toContain("onboarding.banner.next");
|
||||
});
|
||||
|
||||
it("starts with the list folded", () => {
|
||||
const html = renderAt("/reports", loaded(IMPORTED));
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
|
|
|
|||
|
|
@ -43,13 +43,20 @@ function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[]
|
|||
|
||||
function onboardingValue(): OnboardingValue {
|
||||
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["categories", "reports"],
|
||||
});
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "banner",
|
||||
cardPage: null,
|
||||
|
|
@ -57,6 +64,7 @@ function onboardingValue(): OnboardingValue {
|
|||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
acknowledge: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
|
@ -82,16 +90,23 @@ describe("OnboardingBannerSlot (#397)", () => {
|
|||
expect(props.checklist).toBe(value.checklist);
|
||||
expect(props.nextStep).toBe(value.nextStep);
|
||||
expect(props.budgetReady).toBe(true);
|
||||
expect(props.visitStepsInitialized).toBe(true);
|
||||
expect(props.listOpen).toBe(false);
|
||||
expect(props.onHide).toBe(value.hide);
|
||||
expect(props.onResume).toBe(value.resume);
|
||||
});
|
||||
|
||||
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
|
||||
ctx.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
|
||||
ctx.onboarding = {
|
||||
...onboardingValue(),
|
||||
mode: "none",
|
||||
budgetReady: false,
|
||||
visitStepsInitialized: false,
|
||||
};
|
||||
const props = renderSlot();
|
||||
expect(props.mode).toBe("none");
|
||||
expect(props.budgetReady).toBe(false);
|
||||
expect(props.visitStepsInitialized).toBe(false);
|
||||
});
|
||||
|
||||
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export function OnboardingBannerSlot() {
|
|||
checklist={onboarding.checklist}
|
||||
nextStep={onboarding.nextStep}
|
||||
budgetReady={onboarding.budgetReady}
|
||||
visitStepsInitialized={onboarding.visitStepsInitialized}
|
||||
listOpen={listOpen}
|
||||
onToggleList={() => setListOpen((open) => !open)}
|
||||
onHide={onboarding.hide}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const ZERO: OnboardingCounters = {
|
|||
budgetEntries: 0,
|
||||
transactions: 0,
|
||||
};
|
||||
/** Profile, folder and source done: 3/6, the next step is "import" on /import. */
|
||||
/** Profile, folder and source done: 3/8, the next step is "import" on /import. */
|
||||
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
|
||||
/** Up to the import: the next step is "manualCategory" on /transactions. */
|
||||
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
|
||||
|
|
@ -60,12 +60,19 @@ function bannerProps(
|
|||
overrides: Partial<OnboardingBannerProps> = {},
|
||||
counters: OnboardingCounters = THREE_DONE,
|
||||
): OnboardingBannerProps {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
// A template stopped after the import has its visit steps acknowledged (#425).
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
|
||||
});
|
||||
return {
|
||||
mode: checklist.complete ? "complete" : "banner",
|
||||
checklist,
|
||||
nextStep: nextStep(checklist),
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
listOpen: false,
|
||||
onToggleList: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
|
|
@ -94,7 +101,7 @@ describe("OnboardingBanner — banner mode (#397)", () => {
|
|||
const html = markup(bannerProps());
|
||||
expect(html).toContain('role="region"');
|
||||
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
|
||||
expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])");
|
||||
expect(html).toContain("t(onboarding.banner.progress[done=3,total=8])");
|
||||
expect(html).toContain("t(onboarding.banner.next)");
|
||||
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
|
||||
expect(html).toContain("t(onboarding.checklist.hide)");
|
||||
|
|
@ -117,6 +124,12 @@ describe("OnboardingBanner — banner mode (#397)", () => {
|
|||
expect(html).toMatch(/href="\/import"/);
|
||||
});
|
||||
|
||||
it("the counter waits for the visit steps' initialization too, the next step does not (#425)", () => {
|
||||
const html = markup(bannerProps({ visitStepsInitialized: false }));
|
||||
expect(html).not.toContain("onboarding.banner.progress");
|
||||
expect(html).toMatch(/href="\/import"/);
|
||||
});
|
||||
|
||||
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
|
||||
const html = markup(bannerProps());
|
||||
expect(html).toMatch(
|
||||
|
|
@ -134,7 +147,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
|
|||
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
|
||||
expect(container).toBeGreaterThan(-1);
|
||||
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
|
||||
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
|
||||
for (const key of [
|
||||
"profile",
|
||||
"importFolder",
|
||||
"source",
|
||||
"import",
|
||||
"categories",
|
||||
"manualCategory",
|
||||
"budget",
|
||||
"reports",
|
||||
]) {
|
||||
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
|
||||
}
|
||||
});
|
||||
|
|
@ -144,8 +166,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
|
|||
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
|
||||
expect(list.props.items).toBe(props.checklist!.items);
|
||||
expect(list.props.done).toBe(3);
|
||||
expect(list.props.total).toBe(6);
|
||||
expect(list.props.total).toBe(8);
|
||||
expect(list.props.budgetReady).toBe(true);
|
||||
expect(list.props.visitStepsInitialized).toBe(true);
|
||||
// Each flag reaches the list on its own: the Budget row's pending state follows budgetReady alone.
|
||||
const [waiting] = find(
|
||||
tree(bannerProps({ listOpen: true, visitStepsInitialized: false })),
|
||||
(e) => e.type === OnboardingChecklist,
|
||||
);
|
||||
expect(waiting.props.budgetReady).toBe(true);
|
||||
expect(waiting.props.visitStepsInitialized).toBe(false);
|
||||
expect(list.props.onHide).toBe(props.onHide);
|
||||
expect(list.props.onResume).toBe(props.onResume);
|
||||
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export interface OnboardingBannerProps {
|
|||
nextStep: ChecklistItem | null;
|
||||
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
|
||||
budgetReady: boolean;
|
||||
/** False until the visit steps are initialized (#425): the counter waits too, its total depends on them. */
|
||||
visitStepsInitialized: boolean;
|
||||
/** The detailed list is unfolded under the banner. */
|
||||
listOpen: boolean;
|
||||
onToggleList: () => void;
|
||||
|
|
@ -51,7 +53,7 @@ function BannerIcon() {
|
|||
*
|
||||
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
|
||||
* which unfolds the detailed list in place, and "Hide". The counter waits
|
||||
* for `budgetReady`, like the list's own counter.
|
||||
* for `budgetReady && visitStepsInitialized`, like the list's own counter.
|
||||
* - `complete` (D4): the end message, "Open the guide" and "Close", which
|
||||
* hides the path until it is shown again from Settings → Users.
|
||||
* - Anything else (`none`, `card`) renders nothing.
|
||||
|
|
@ -61,12 +63,14 @@ export default function OnboardingBanner({
|
|||
checklist,
|
||||
nextStep,
|
||||
budgetReady,
|
||||
visitStepsInitialized,
|
||||
listOpen,
|
||||
onToggleList,
|
||||
onHide,
|
||||
onResume,
|
||||
}: OnboardingBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const totalKnown = budgetReady && visitStepsInitialized;
|
||||
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
|
||||
|
||||
if (mode === "complete") {
|
||||
|
|
@ -92,12 +96,12 @@ export default function OnboardingBanner({
|
|||
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
|
||||
<div className={ROW_CLASS}>
|
||||
<BannerIcon />
|
||||
{budgetReady && (
|
||||
{totalKnown && (
|
||||
<span className="font-semibold text-[var(--foreground)] tabular-nums">
|
||||
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
|
||||
</span>
|
||||
)}
|
||||
{budgetReady && nextStep && (
|
||||
{totalKnown && nextStep && (
|
||||
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
|
||||
)}
|
||||
{nextStep && (
|
||||
|
|
@ -133,6 +137,7 @@ export default function OnboardingBanner({
|
|||
done={checklist.done}
|
||||
total={checklist.total}
|
||||
budgetReady={budgetReady}
|
||||
visitStepsInitialized={visitStepsInitialized}
|
||||
onHide={onHide}
|
||||
onResume={onResume}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,21 @@ vi.mock("react-router-dom", async (importOriginal) => ({
|
|||
useNavigate: () => ctx.navigate,
|
||||
}));
|
||||
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
||||
// /categories guides no step before its card ships (#432): a stand-in page
|
||||
// that guides the categories step, so the wiring of "Got it" is exercised now.
|
||||
vi.mock("../../utils/onboardingPath", async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import("../../utils/onboardingPath")>();
|
||||
return {
|
||||
...orig,
|
||||
cardStep: (...args: Parameters<typeof orig.cardStep>) => {
|
||||
const [checklist, pathname] = args;
|
||||
if (pathname !== "/stand-in-categories") return orig.cardStep(checklist, pathname);
|
||||
const unlocked = checklist.items.filter((item) => !item.locked);
|
||||
const step = unlocked.find((item) => item.key === "categories")!;
|
||||
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { hookHarness } from "../../test/hookHarness";
|
||||
import OnboardingStepCard from "./OnboardingStepCard";
|
||||
|
|
@ -46,13 +61,20 @@ const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transact
|
|||
|
||||
/** The path latched on /import, as the provider shares it. */
|
||||
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
// A template stopped after the import has its visit steps acknowledged (#425).
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
|
||||
});
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "card",
|
||||
cardPage: "/import",
|
||||
|
|
@ -60,6 +82,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
|
|||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
acknowledge: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -125,7 +148,7 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
|||
it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => {
|
||||
const props = render()!.props;
|
||||
expect(props.step.key).toBe("source");
|
||||
expect(props.position).toEqual({ n: 3, total: 6 });
|
||||
expect(props.position).toEqual({ n: 3, total: 8 });
|
||||
expect(props.text).toBe(SOURCE_LIST.text);
|
||||
expect(props.done).toBe(false);
|
||||
expect(props.titleRef).toBe(titleRef());
|
||||
|
|
@ -147,6 +170,39 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
|||
expect(render()!.props.position).toBeNull();
|
||||
});
|
||||
|
||||
it("no 'Step n of N' and no end until the visit steps are initialized (#425)", () => {
|
||||
ctx.onboarding = path(FOLDER_SET, { visitStepsInitialized: false });
|
||||
expect(render()!.props.position).toBeNull();
|
||||
ctx.onboarding = path(IMPORTED, { visitStepsInitialized: false, nextStep: null });
|
||||
expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
|
||||
ctx.onboarding = path(IMPORTED, { nextStep: null });
|
||||
expect(render(DONE)!.props.next).toEqual({ kind: "end" });
|
||||
});
|
||||
|
||||
it("no 'Got it' on a data step", () => {
|
||||
expect(render()!.props.onAcknowledge).toBeUndefined();
|
||||
});
|
||||
|
||||
it("'Got it' on a visit step acknowledges the step the card shows (#425)", () => {
|
||||
ctx.location = { pathname: "/stand-in-categories", state: null, key: "arrival-1" };
|
||||
ctx.onboarding = path(IMPORTED, {
|
||||
cardPage: "/stand-in-categories",
|
||||
checklist: computeChecklist(IMPORTED, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
}),
|
||||
});
|
||||
const value = ctx.onboarding as OnboardingValue;
|
||||
const props = render()!.props;
|
||||
expect(props.step.key).toBe("categories");
|
||||
expect(props.position).toEqual({ n: 5, total: 8 });
|
||||
expect(props.onAcknowledge).toBeTypeOf("function");
|
||||
props.onAcknowledge!();
|
||||
expect(value.acknowledge).toHaveBeenCalledWith("categories");
|
||||
expect(value.skip).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
|
||||
const value = ctx.onboarding as OnboardingValue;
|
||||
const props = render()!.props;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
showsStepCard,
|
||||
type StepCardModel,
|
||||
} from "../../utils/onboardingPath";
|
||||
import { VISIT_STEP_KEYS } from "../../shared/types";
|
||||
|
||||
export interface OnboardingStepCardProps {
|
||||
/** The page's card model; null for no card yet (the page's own data is not read). */
|
||||
|
|
@ -28,8 +29,11 @@ export interface OnboardingStepCardProps {
|
|||
* then consumes the navigation state, once per arrival: StrictMode runs the
|
||||
* effect twice on mount, a guard on the location key keeps it to one focus
|
||||
* and one replace. An ordinary visit carries no state and does nothing.
|
||||
* - It wires "Skip this step" to the step it shows, "Close" to `hide`, and
|
||||
* the next step's link to the path's next step.
|
||||
* - It wires "Skip this step" to the step it shows, "Got it" to
|
||||
* `acknowledge` when that step is a visit step (#425), "Close" to `hide`,
|
||||
* and the next step's link to the path's next step.
|
||||
* - Its rank "Step n of N" and its end wait for the Budget entitlement and
|
||||
* the visit steps' initialization (#425): N depends on both.
|
||||
*/
|
||||
export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
|
||||
const onboarding = useOnboarding();
|
||||
|
|
@ -55,15 +59,18 @@ export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
|
|||
|
||||
if (!shown) return null;
|
||||
|
||||
const { skip, hide } = onboarding;
|
||||
const { skip, acknowledge, hide } = onboarding;
|
||||
const totalKnown = onboarding.budgetReady && onboarding.visitStepsInitialized;
|
||||
const isVisitStep = VISIT_STEP_KEYS.includes(current.step.key);
|
||||
return (
|
||||
<StepCard
|
||||
step={current.step}
|
||||
position={onboarding.budgetReady ? { n: current.n, total: current.total } : null}
|
||||
position={totalKnown ? { n: current.n, total: current.total } : null}
|
||||
text={model.text}
|
||||
done={model.done}
|
||||
next={cardNext(onboarding.nextStep, pathname, onboarding.budgetReady)}
|
||||
next={cardNext(onboarding.nextStep, pathname, totalKnown)}
|
||||
onSkip={() => void skip(current.step.key)}
|
||||
onAcknowledge={isVisitStep ? () => void acknowledge(current.step.key) : undefined}
|
||||
onHide={() => void hide()}
|
||||
titleRef={titleRef}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -40,15 +40,19 @@ const ZERO: OnboardingCounters = {
|
|||
transactions: 0,
|
||||
};
|
||||
const rows = (counters: OnboardingCounters = ZERO) =>
|
||||
computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }).items;
|
||||
computeChecklist(counters, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
}).items;
|
||||
const row = (key: ChecklistItemKey, counters?: OnboardingCounters): ChecklistItem =>
|
||||
rows(counters).find((item) => item.key === key)!;
|
||||
|
||||
/** The source step on /import, step 3 of 6, not done yet. */
|
||||
/** The source step on /import, step 3 of 8, not done yet. */
|
||||
function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
|
||||
return {
|
||||
step: row("source"),
|
||||
position: { n: 3, total: 6 },
|
||||
position: { n: 3, total: 8 },
|
||||
text: { key: "onboarding.card.import.sourceList" },
|
||||
done: false,
|
||||
next: { kind: "wait" },
|
||||
|
|
@ -62,7 +66,7 @@ function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
|
|||
const doneProps = (overrides: Partial<StepCardProps> = {}) =>
|
||||
cardProps({
|
||||
step: row("import", { ...ZERO, sources: 1, imports: 1 }),
|
||||
position: { n: 4, total: 6 },
|
||||
position: { n: 4, total: 8 },
|
||||
text: null,
|
||||
done: true,
|
||||
next: { kind: "step", step: row("manualCategory") },
|
||||
|
|
@ -92,7 +96,7 @@ describe("StepCard — the card (#398)", () => {
|
|||
expect(heading).not.toBeNull();
|
||||
expect(heading![0]).toContain('tabindex="-1"');
|
||||
const title = heading![1];
|
||||
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=6])");
|
||||
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=8])");
|
||||
expect(title.indexOf("onboarding.card.stepOf")).toBeLessThan(
|
||||
title.indexOf("t(onboarding.checklist.items.source)"),
|
||||
);
|
||||
|
|
@ -204,3 +208,52 @@ describe("StepCard — end of the path (#398)", () => {
|
|||
expect(html).toContain("t(onboarding.card.skip)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StepCard — \"Got it\" on a visit step (#425)", () => {
|
||||
/** The categories step, step 5 of 8, not acknowledged yet. */
|
||||
const visitProps = (overrides: Partial<StepCardProps> = {}) =>
|
||||
cardProps({
|
||||
step: row("categories"),
|
||||
position: { n: 5, total: 8 },
|
||||
text: null,
|
||||
onAcknowledge: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("offers 'Got it' before 'Skip this step', wired to onAcknowledge", () => {
|
||||
const props = visitProps();
|
||||
const html = markup(props);
|
||||
expect(html).toContain("t(onboarding.card.acknowledge)");
|
||||
expect(html.indexOf("t(onboarding.card.acknowledge)")).toBeLessThan(
|
||||
html.indexOf("t(onboarding.card.skip)"),
|
||||
);
|
||||
const buttons = find(tree(props), (e) => e.type === "button");
|
||||
expect(buttons.map((b) => texts(b.props.children))).toEqual([
|
||||
["t(onboarding.card.acknowledge)"],
|
||||
["t(onboarding.card.skip)"],
|
||||
]);
|
||||
(buttons[0].props.onClick as () => void)();
|
||||
expect(props.onAcknowledge).toHaveBeenCalledTimes(1);
|
||||
expect(props.onSkip).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is never there without onAcknowledge", () => {
|
||||
expect(markup(cardProps())).not.toContain("onboarding.card.acknowledge");
|
||||
expect(markup(visitProps({ onAcknowledge: undefined }))).not.toContain(
|
||||
"onboarding.card.acknowledge",
|
||||
);
|
||||
});
|
||||
|
||||
it("is never there on a done step, nor once the page's steps are done", () => {
|
||||
const acknowledged = computeChecklist(ZERO, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["categories"],
|
||||
}).items.find((item) => item.key === "categories")!;
|
||||
expect(markup(visitProps({ step: acknowledged }))).not.toContain("onboarding.card.acknowledge");
|
||||
const done = markup(visitProps({ done: true, next: { kind: "step", step: row("manualCategory") } }));
|
||||
expect(done).not.toContain("onboarding.card.acknowledge");
|
||||
expect(done).toContain("t(onboarding.card.done)");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { ChecklistItem } from "../../shared/types";
|
|||
export interface StepCardProps {
|
||||
/** The step the card shows (`cardStep`): its label is the title. */
|
||||
step: ChecklistItem;
|
||||
/** "Step n of N", null until the Budget entitlement is known: N depends on its lock. */
|
||||
/** "Step n of N", null until the Budget entitlement is known and the visit steps initialized: N depends on both. */
|
||||
position: { n: number; total: number } | null;
|
||||
/** The page's sentence, null when "Done." says it all. */
|
||||
text: StepCardText | null;
|
||||
|
|
@ -23,6 +23,8 @@ export interface StepCardProps {
|
|||
next: StepCardNext;
|
||||
/** "Skip this step": the provider's `skip`, for `step`. */
|
||||
onSkip: () => void;
|
||||
/** "Got it" on a visit step (#425): the provider's `acknowledge`, for `step`. No button when omitted. */
|
||||
onAcknowledge?: () => void;
|
||||
/** "Close" at the end of the path: the provider's `hide`. */
|
||||
onHide: () => void;
|
||||
/** The title, which takes the focus after an arrival by "Next step". */
|
||||
|
|
@ -33,6 +35,8 @@ const REGION_CLASS =
|
|||
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
|
||||
const TEXT_BUTTON_CLASS =
|
||||
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
|
||||
const ACKNOWLEDGE_BUTTON_CLASS =
|
||||
"rounded-lg bg-[var(--primary)] px-3 py-1 font-medium text-white hover:opacity-90 transition-opacity";
|
||||
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
|
||||
|
||||
/**
|
||||
|
|
@ -48,7 +52,8 @@ const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--prima
|
|||
* "Done." it receives once the page's steps are done is announced.
|
||||
* - "Skip this step" shows until the page's steps are done, and only while
|
||||
* the step itself is not: skipping a done step would only leave a stale
|
||||
* "skipped" mark behind.
|
||||
* "skipped" mark behind. A visit step (#425) gets "Got it" before it, on
|
||||
* the same condition, when `onAcknowledge` is given.
|
||||
* - Once done: "Next step: <step> →", linked to its page with the step's
|
||||
* navigation state; the end message, "Open the guide" and "Close" when no
|
||||
* step is left; nothing yet while `next` waits.
|
||||
|
|
@ -60,6 +65,7 @@ export default function StepCard({
|
|||
done,
|
||||
next,
|
||||
onSkip,
|
||||
onAcknowledge,
|
||||
onHide,
|
||||
titleRef,
|
||||
}: StepCardProps) {
|
||||
|
|
@ -105,6 +111,11 @@ export default function StepCard({
|
|||
</>
|
||||
)}
|
||||
</p>
|
||||
{!done && !step.done && onAcknowledge && (
|
||||
<button type="button" onClick={onAcknowledge} className={ACKNOWLEDGE_BUTTON_CLASS}>
|
||||
{t("onboarding.card.acknowledge")}
|
||||
</button>
|
||||
)}
|
||||
{!done && !step.done && (
|
||||
<button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}>
|
||||
{t("onboarding.card.skip")}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ function Probe() {
|
|||
String(onboarding.checklist),
|
||||
typeof onboarding.skip,
|
||||
typeof onboarding.refresh,
|
||||
typeof onboarding.acknowledge,
|
||||
String(onboarding.visitStepsInitialized),
|
||||
].join("|")}
|
||||
</output>
|
||||
);
|
||||
|
|
@ -51,7 +53,7 @@ describe("OnboardingProvider (#396)", () => {
|
|||
</OnboardingProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(html).toContain("loading|none|null|null|null|function|function");
|
||||
expect(html).toContain("loading|none|null|null|null|function|function|function|false");
|
||||
});
|
||||
|
||||
it("AppShell mounts the provider around the routed page", () => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ import {
|
|||
onboardingChecklistReducer,
|
||||
initialOnboardingChecklistState,
|
||||
createOnboardingLoader,
|
||||
createVisitStepsInitializer,
|
||||
deriveOnboardingView,
|
||||
needsVisitStepsInitialization,
|
||||
followDataChanges,
|
||||
DATA_CHANGE_DELAY_MS,
|
||||
type OnboardingChecklistAction,
|
||||
|
|
@ -29,7 +31,7 @@ import {
|
|||
type OnboardingLoaderDeps,
|
||||
} from "./useOnboardingChecklist";
|
||||
import { notifyDataChanged } from "../services/dataEvents";
|
||||
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||
import { computeChecklist, shouldAcknowledgeVisitSteps } from "../utils/onboardingChecklist";
|
||||
import type { OnboardingSnapshot } from "../services/onboardingService";
|
||||
import type { OnboardingCounters } from "../shared/types";
|
||||
|
||||
|
|
@ -38,6 +40,10 @@ const snapshot: OnboardingSnapshot = {
|
|||
importFolderSet: true,
|
||||
hidden: false,
|
||||
skipped: [],
|
||||
// Stopped after the import: the visit steps are acknowledged and initialized
|
||||
// (rule of the templates, #425).
|
||||
acknowledged: ["categories", "reports"],
|
||||
visitStepsInitialized: true,
|
||||
};
|
||||
|
||||
const reduce = (
|
||||
|
|
@ -60,9 +66,12 @@ describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => {
|
|||
importFolderSet: false,
|
||||
hidden: false,
|
||||
skipped: [],
|
||||
acknowledged: [],
|
||||
visitStepsInitialized: false,
|
||||
loadSeq: 0,
|
||||
hiddenSetAt: 0,
|
||||
skippedSetAt: 0,
|
||||
acknowledgedSetAt: 0,
|
||||
cardPage: null,
|
||||
});
|
||||
});
|
||||
|
|
@ -318,6 +327,76 @@ describe("onboardingChecklistReducer — skipped steps and the card latch (#396)
|
|||
).toBe("/import");
|
||||
});
|
||||
|
||||
it("ACKNOWLEDGE keeps checklist order, never duplicates a step, and keeps the card latch (#425)", () => {
|
||||
const latched = reduce(
|
||||
[{ type: "SET_CARD_PAGE", cardPage: "/categories" }],
|
||||
ready({ ...snapshot, acknowledged: [] }),
|
||||
);
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "ACKNOWLEDGE", key: "reports" },
|
||||
{ type: "ACKNOWLEDGE", key: "categories" },
|
||||
{ type: "ACKNOWLEDGE", key: "reports" },
|
||||
],
|
||||
latched,
|
||||
);
|
||||
expect(s.acknowledged).toEqual(["categories", "reports"]);
|
||||
expect(s.cardPage).toBe("/categories");
|
||||
expect(s.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("ACKNOWLEDGE during a load wins over that load's snapshot; a later load brings the stored value back (#425)", () => {
|
||||
const base = ready({ ...snapshot, acknowledged: [] });
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "ACKNOWLEDGE", key: "categories" },
|
||||
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, acknowledged: [] } },
|
||||
],
|
||||
base,
|
||||
);
|
||||
expect(s.acknowledged).toEqual(["categories"]);
|
||||
const later = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 3 },
|
||||
{ type: "LOAD_DONE", seq: 3, snapshot: { ...snapshot, acknowledged: ["categories"] } },
|
||||
],
|
||||
s,
|
||||
);
|
||||
expect(later.acknowledged).toEqual(["categories"]);
|
||||
});
|
||||
|
||||
it("ACKNOWLEDGE does not shield the skipped steps, nor SKIP the acknowledged ones (#425)", () => {
|
||||
const base = ready({ ...snapshot, acknowledged: [] });
|
||||
const s = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 2 },
|
||||
{ type: "ACKNOWLEDGE", key: "categories" },
|
||||
{
|
||||
type: "LOAD_DONE",
|
||||
seq: 2,
|
||||
snapshot: { ...snapshot, acknowledged: [], skipped: ["budget"] },
|
||||
},
|
||||
],
|
||||
base,
|
||||
);
|
||||
expect(s.skipped).toEqual(["budget"]);
|
||||
const t = reduce(
|
||||
[
|
||||
{ type: "LOAD_START", seq: 3 },
|
||||
{ type: "SKIP", key: "budget" },
|
||||
{ type: "LOAD_DONE", seq: 3, snapshot: { ...snapshot, acknowledged: ["reports"] } },
|
||||
],
|
||||
s,
|
||||
);
|
||||
expect(t.acknowledged).toEqual(["reports"]);
|
||||
});
|
||||
|
||||
it("LOAD_DONE takes visitStepsInitialized from the snapshot (#425)", () => {
|
||||
expect(ready({ ...snapshot, visitStepsInitialized: false }).visitStepsInitialized).toBe(false);
|
||||
expect(ready(snapshot).visitStepsInitialized).toBe(true);
|
||||
});
|
||||
|
||||
it("SET_CARD_PAGE with the same page returns the same state object", () => {
|
||||
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
|
||||
expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe(
|
||||
|
|
@ -465,6 +544,82 @@ describe("createOnboardingLoader (#396)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visit steps' one-time initialization (#425)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createVisitStepsInitializer (#425)", () => {
|
||||
it("runs initializeVisitSteps once, then re-reads, whatever the number of calls", async () => {
|
||||
const init = deferred<void>();
|
||||
const deps = {
|
||||
initializeVisitSteps: vi.fn(() => init.promise),
|
||||
refresh: vi.fn(async () => {}),
|
||||
logError: vi.fn(),
|
||||
};
|
||||
const run = createVisitStepsInitializer(deps);
|
||||
// StrictMode's double mount, then renders before the re-read lands.
|
||||
const first = run(true);
|
||||
const second = run(false);
|
||||
expect(deps.refresh).not.toHaveBeenCalled();
|
||||
init.resolve();
|
||||
await Promise.all([first, second]);
|
||||
await run(true);
|
||||
expect(deps.initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||
expect(deps.initializeVisitSteps).toHaveBeenCalledWith(true);
|
||||
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a failure is logged, without a re-read, and not retried by the same instance", async () => {
|
||||
const deps = {
|
||||
initializeVisitSteps: vi.fn(async () => {
|
||||
throw new Error("database is locked");
|
||||
}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
logError: vi.fn(),
|
||||
};
|
||||
const run = createVisitStepsInitializer(deps);
|
||||
await run(false);
|
||||
await run(false);
|
||||
expect(deps.initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||
expect(deps.refresh).not.toHaveBeenCalled();
|
||||
expect(deps.logError).toHaveBeenCalledWith(
|
||||
"Onboarding: failed to initialize the visit steps",
|
||||
"database is locked",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsVisitStepsInitialization (#425)", () => {
|
||||
const notYet = { ...snapshot, acknowledged: [], visitStepsInitialized: false };
|
||||
const listOf = (state: OnboardingChecklistState) =>
|
||||
computeChecklist(state.counters!, {
|
||||
importFolderSet: state.importFolderSet,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: state.visitStepsInitialized,
|
||||
});
|
||||
|
||||
it("true after a successful load, with the entitlement known and the flag unset", () => {
|
||||
const s = ready(notYet);
|
||||
expect(needsVisitStepsInitialization(s, listOf(s), true)).toBe(true);
|
||||
});
|
||||
|
||||
it("false before budgetReady: the checklist would count Budget as locked", () => {
|
||||
const s = ready(notYet);
|
||||
expect(needsVisitStepsInitialization(s, listOf(s), false)).toBe(false);
|
||||
});
|
||||
|
||||
it("false before any load, after a failed first load, and once the flag is set", () => {
|
||||
expect(needsVisitStepsInitialization(initialOnboardingChecklistState, null, true)).toBe(false);
|
||||
const failed = reduce([
|
||||
{ type: "LOAD_START", seq: 1 },
|
||||
{ type: "LOAD_ERROR", seq: 1 },
|
||||
]);
|
||||
expect(needsVisitStepsInitialization(failed, null, true)).toBe(false);
|
||||
const done = ready(snapshot);
|
||||
expect(needsVisitStepsInitialization(done, listOf(done), true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider view: the path end to end, without rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -492,6 +647,8 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
|||
importFolderSet: state.importFolderSet,
|
||||
budgetAllowed,
|
||||
skipped: state.skipped,
|
||||
acknowledged: state.acknowledged,
|
||||
visitStepsInitialized: state.visitStepsInitialized,
|
||||
})
|
||||
: null;
|
||||
const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages });
|
||||
|
|
@ -517,7 +674,16 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
|||
);
|
||||
}
|
||||
|
||||
const fresh: OnboardingSnapshot = { counters: empty, importFolderSet: false, hidden: false, skipped: [] };
|
||||
// The walks below cover the six data steps: the visit steps are acknowledged
|
||||
// and initialized (rule of the templates, #425); their own walk follows.
|
||||
const fresh: OnboardingSnapshot = {
|
||||
counters: empty,
|
||||
importFolderSet: false,
|
||||
hidden: false,
|
||||
skipped: [],
|
||||
acknowledged: ["categories", "reports"],
|
||||
visitStepsInitialized: true,
|
||||
};
|
||||
|
||||
it("shows nothing before the first load", () => {
|
||||
const { view } = render(initialOnboardingChecklistState, "/import");
|
||||
|
|
@ -652,7 +818,7 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("Free edition: five steps, never the budget card, and the end at 5/5", () => {
|
||||
it("Free edition: seven steps, never the budget card, and the end at 7/7", () => {
|
||||
const snap = {
|
||||
...fresh,
|
||||
importFolderSet: true,
|
||||
|
|
@ -660,7 +826,7 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
|||
};
|
||||
const s = load(initialOnboardingChecklistState, snap);
|
||||
const r = render(s, "/budget", { budgetAllowed: false });
|
||||
expect(r.checklist?.total).toBe(5);
|
||||
expect(r.checklist?.total).toBe(7);
|
||||
expect(r.view.nextStep).toBeNull();
|
||||
expect(r.state.cardPage).toBeNull();
|
||||
expect(r.view.mode).toBe("complete");
|
||||
|
|
@ -677,7 +843,74 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
|||
);
|
||||
r = render(s, "/import");
|
||||
expect(r.view.mode).toBe("card");
|
||||
expect(r.checklist?.total).toBe(6);
|
||||
expect(r.checklist?.total).toBe(8);
|
||||
});
|
||||
|
||||
// The visit steps (#425): until they are initialized, a profile whose path
|
||||
// was complete must not flash a "Next step" towards them.
|
||||
const sixDone = { ...empty, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 20 };
|
||||
const beforeInit: OnboardingSnapshot = {
|
||||
...fresh,
|
||||
importFolderSet: true,
|
||||
counters: sixDone,
|
||||
acknowledged: [],
|
||||
visitStepsInitialized: false,
|
||||
};
|
||||
|
||||
it("first render of a profile whose path was complete: no banner before the initialization, then the end", () => {
|
||||
let s = load(initialOnboardingChecklistState, beforeInit);
|
||||
let r = render(s, "/reports");
|
||||
expect(r.view.nextStep).toBeNull();
|
||||
expect(r.view.mode).toBe("none");
|
||||
expect(r.view.mode).not.toBe("banner");
|
||||
expect(needsVisitStepsInitialization(s, r.checklist, true)).toBe(true);
|
||||
expect(shouldAcknowledgeVisitSteps(r.checklist!)).toBe(true);
|
||||
// The initializer wrote both acknowledgements and the flag, then re-read.
|
||||
s = load(r.state, { ...beforeInit, acknowledged: ["categories", "reports"], visitStepsInitialized: true });
|
||||
r = render(s, "/reports");
|
||||
expect(r.checklist?.total).toBe(8);
|
||||
expect(r.view.mode).toBe("complete");
|
||||
});
|
||||
|
||||
it("an incomplete path keeps its visit steps to do: after the import, the next step is to browse the categories", () => {
|
||||
const imported = { ...empty, sources: 1, imports: 1, transactions: 20 };
|
||||
let s = load(initialOnboardingChecklistState, {
|
||||
...beforeInit,
|
||||
counters: imported,
|
||||
});
|
||||
let r = render(s, "/import");
|
||||
expect(shouldAcknowledgeVisitSteps(r.checklist!)).toBe(false);
|
||||
s = load(r.state, { ...beforeInit, counters: imported, visitStepsInitialized: true });
|
||||
r = render(s, "/import");
|
||||
expect(r.view.nextStep?.key).toBe("categories");
|
||||
expect(r.checklist?.total).toBe(8);
|
||||
// The six other steps done: still two to do.
|
||||
s = load(r.state, { ...beforeInit, visitStepsInitialized: true });
|
||||
r = render(s, "/");
|
||||
expect(r.view.nextStep?.key).toBe("categories");
|
||||
expect(r.view.mode).toBe("banner");
|
||||
});
|
||||
|
||||
it('"Got it" on the last step ends the path; the latched card stays to say so', () => {
|
||||
let s = load(initialOnboardingChecklistState, {
|
||||
...beforeInit,
|
||||
acknowledged: ["categories"],
|
||||
visitStepsInitialized: true,
|
||||
});
|
||||
s = reduce([{ type: "SET_CARD_PAGE", cardPage: "/reports" }], s);
|
||||
let r = render(s, "/reports", {});
|
||||
expect(r.view.nextStep?.key).toBe("reports");
|
||||
r = render(reduce([{ type: "ACKNOWLEDGE", key: "reports" }], r.state), "/reports");
|
||||
expect(r.view.nextStep).toBeNull();
|
||||
expect(r.checklist?.complete).toBe(true);
|
||||
expect(r.state.cardPage).toBe("/reports");
|
||||
expect(r.view.mode).toBe("card");
|
||||
});
|
||||
|
||||
it("the end waits for the visit steps' initialization even with the entitlement known", () => {
|
||||
const s = load(initialOnboardingChecklistState, beforeInit);
|
||||
expect(render(s, "/").view.mode).toBe("none");
|
||||
expect(render(load(s, { ...beforeInit, visitStepsInitialized: true }), "/").view.mode).toBe("banner");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import {
|
||||
acknowledgeStep,
|
||||
getOnboardingSnapshot,
|
||||
hideChecklist,
|
||||
initializeGuidedPath,
|
||||
initializeVisitSteps,
|
||||
resumeStep,
|
||||
showChecklist,
|
||||
skipStep,
|
||||
|
|
@ -11,7 +13,11 @@ import {
|
|||
} from "../services/onboardingService";
|
||||
import { subscribeDataChanged } from "../services/dataEvents";
|
||||
import { logError } from "../services/logService";
|
||||
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
|
||||
import {
|
||||
computeChecklist,
|
||||
shouldAcknowledgeVisitSteps,
|
||||
type Checklist,
|
||||
} from "../utils/onboardingChecklist";
|
||||
import {
|
||||
bannerMode,
|
||||
latchCardPage,
|
||||
|
|
@ -44,12 +50,18 @@ export interface OnboardingChecklistState {
|
|||
hidden: boolean;
|
||||
/** Skipped steps, in checklist order. */
|
||||
skipped: ChecklistItemKey[];
|
||||
/** Visit steps done by "Got it" (#425), in checklist order. */
|
||||
acknowledged: ChecklistItemKey[];
|
||||
/** The profile's visit steps are initialized (#425); read from the snapshot only. */
|
||||
visitStepsInitialized: boolean;
|
||||
/** Number of the last load started: the outcome of any other load is stale. */
|
||||
loadSeq: number;
|
||||
/** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */
|
||||
hiddenSetAt: number;
|
||||
/** Same, for SKIP / RESUME and the skipped steps. */
|
||||
skippedSetAt: number;
|
||||
/** Same, for ACKNOWLEDGE and the acknowledged steps (#425). */
|
||||
acknowledgedSetAt: number;
|
||||
/** The page that carries a step card (`latchCardPage`), null otherwise. */
|
||||
cardPage: string | null;
|
||||
}
|
||||
|
|
@ -62,6 +74,7 @@ export type OnboardingChecklistAction =
|
|||
| { type: "SHOW" }
|
||||
| { type: "SKIP"; key: ChecklistItemKey }
|
||||
| { type: "RESUME"; key: ChecklistItemKey }
|
||||
| { type: "ACKNOWLEDGE"; key: ChecklistItemKey }
|
||||
| { type: "SET_CARD_PAGE"; cardPage: string | null };
|
||||
|
||||
export const initialOnboardingChecklistState: OnboardingChecklistState = {
|
||||
|
|
@ -70,9 +83,12 @@ export const initialOnboardingChecklistState: OnboardingChecklistState = {
|
|||
importFolderSet: false,
|
||||
hidden: false,
|
||||
skipped: [],
|
||||
acknowledged: [],
|
||||
visitStepsInitialized: false,
|
||||
loadSeq: 0,
|
||||
hiddenSetAt: 0,
|
||||
skippedSetAt: 0,
|
||||
acknowledgedSetAt: 0,
|
||||
cardPage: null,
|
||||
};
|
||||
|
||||
|
|
@ -106,6 +122,9 @@ export function onboardingChecklistReducer(
|
|||
// snapshot, read before the action's write (#384).
|
||||
hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden,
|
||||
skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped,
|
||||
acknowledged:
|
||||
action.seq <= state.acknowledgedSetAt ? state.acknowledged : snapshot.acknowledged,
|
||||
visitStepsInitialized: snapshot.visitStepsInitialized,
|
||||
};
|
||||
}
|
||||
case "LOAD_ERROR":
|
||||
|
|
@ -132,6 +151,14 @@ export function onboardingChecklistReducer(
|
|||
skipped: state.skipped.filter((key) => key !== action.key),
|
||||
skippedSetAt: state.loadSeq,
|
||||
};
|
||||
case "ACKNOWLEDGE":
|
||||
// Wins over an older snapshot like SKIP, but keeps the card latch: the
|
||||
// card stays to say "Done." and offer the next step (#425).
|
||||
return {
|
||||
...state,
|
||||
acknowledged: withStep(state.acknowledged, action.key),
|
||||
acknowledgedSetAt: state.loadSeq,
|
||||
};
|
||||
case "SET_CARD_PAGE":
|
||||
return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage };
|
||||
}
|
||||
|
|
@ -199,6 +226,53 @@ export function createOnboardingLoader(deps: OnboardingLoaderDeps): () => Promis
|
|||
};
|
||||
}
|
||||
|
||||
export interface VisitStepsInitializerDeps {
|
||||
initializeVisitSteps: (acknowledge: boolean) => Promise<void>;
|
||||
/** The provider's loader: the initializer re-reads once it wrote. */
|
||||
refresh: () => Promise<void>;
|
||||
logError: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-time initialization of the visit steps (#425), kept outside React
|
||||
* like the loader. The provider calls the returned function after each render
|
||||
* where a load succeeded, the Budget entitlement is known and the flag is
|
||||
* unset; it runs `initializeVisitSteps` once per instance, whatever the number
|
||||
* of calls (the promise is shared, so the double mount of StrictMode and the
|
||||
* renders before the re-read lands do not run it twice), then re-reads. A
|
||||
* failure is logged and the flag stays unset: the next opening retries.
|
||||
*/
|
||||
export function createVisitStepsInitializer(
|
||||
deps: VisitStepsInitializerDeps,
|
||||
): (acknowledge: boolean) => Promise<void> {
|
||||
let initialization: Promise<void> | null = null;
|
||||
return (acknowledge) => {
|
||||
if (initialization === null) {
|
||||
initialization = deps.initializeVisitSteps(acknowledge).then(
|
||||
() => deps.refresh(),
|
||||
(e: unknown) => {
|
||||
deps.logError("Onboarding: failed to initialize the visit steps", errorMessage(e));
|
||||
},
|
||||
);
|
||||
}
|
||||
return initialization;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this render must start the visit steps' initialization (#425): a
|
||||
* load succeeded, the Budget entitlement is known (the checklist counts Budget
|
||||
* as locked until then, and a Base profile would be judged complete one step
|
||||
* early) and the snapshot says the flag is unset.
|
||||
*/
|
||||
export function needsVisitStepsInitialization(
|
||||
state: OnboardingChecklistState,
|
||||
checklist: Checklist | null,
|
||||
budgetReady: boolean,
|
||||
): checklist is Checklist {
|
||||
return state.status === "ready" && checklist !== null && budgetReady && !state.visitStepsInitialized;
|
||||
}
|
||||
|
||||
/** Wait after a data event before re-reading: a burst of writes costs one read (ADR 0022). */
|
||||
export const DATA_CHANGE_DELAY_MS = 150;
|
||||
|
||||
|
|
@ -255,6 +329,9 @@ export function deriveOnboardingView({
|
|||
budgetReady,
|
||||
cardPages,
|
||||
}: OnboardingViewInput): OnboardingView {
|
||||
// The end of the path waits for the visit steps too (#425): before their
|
||||
// initialization, a profile whose path was complete is two steps short.
|
||||
const totalKnown = budgetReady && state.visitStepsInitialized;
|
||||
const nextStep = checklist ? findNextStep(checklist) : null;
|
||||
const cardPage = latchCardPage(state.cardPage, {
|
||||
pathname,
|
||||
|
|
@ -268,7 +345,7 @@ export function deriveOnboardingView({
|
|||
hidden: state.hidden,
|
||||
pathname,
|
||||
isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0,
|
||||
budgetReady,
|
||||
budgetReady: totalKnown,
|
||||
cardPage,
|
||||
});
|
||||
return { nextStep, cardPage, mode };
|
||||
|
|
@ -282,6 +359,11 @@ export interface OnboardingValue {
|
|||
counters: OnboardingCounters | null;
|
||||
hidden: boolean;
|
||||
budgetReady: boolean;
|
||||
/**
|
||||
* The profile's visit steps are initialized (#425). The counters, the rank
|
||||
* of a card and the end of the path wait for `budgetReady && visitStepsInitialized`.
|
||||
*/
|
||||
visitStepsInitialized: boolean;
|
||||
/** First unlocked step neither done nor skipped; its link is `nextStep.to`. */
|
||||
nextStep: ChecklistItem | null;
|
||||
/** What the current page shows of the path (`bannerMode`). */
|
||||
|
|
@ -292,18 +374,22 @@ export interface OnboardingValue {
|
|||
show: () => Promise<void>;
|
||||
skip: (key: ChecklistItemKey) => Promise<void>;
|
||||
resume: (key: ChecklistItemKey) => Promise<void>;
|
||||
/** "Got it" on a visit step's card (#425). */
|
||||
acknowledge: (key: ChecklistItemKey) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getting-started path: counters read from the profile database, hidden and
|
||||
* skipped preferences, the Budget entitlement that decides whether the sixth
|
||||
* skipped and acknowledged preferences, the visit-steps flag (#425), the
|
||||
* Budget entitlement that decides whether the seventh
|
||||
* row is locked, and what each page shows of it. Called once, by
|
||||
* `OnboardingProvider`; pages read it through `useOnboarding()`.
|
||||
*
|
||||
* Re-read at mount, at every route change and 150 ms after a burst of data
|
||||
* events (ADR 0022). The provider is the only writer of the onboarding
|
||||
* preferences: its actions update the state themselves, without a re-read.
|
||||
* preferences: its actions update the state themselves, without a re-read;
|
||||
* only the visit steps' one-time initialization (#425) re-reads after it wrote.
|
||||
*/
|
||||
export function useOnboardingChecklist(): OnboardingValue {
|
||||
const [state, dispatch] = useReducer(
|
||||
|
|
@ -319,6 +405,11 @@ export function useOnboardingChecklist(): OnboardingValue {
|
|||
createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
|
||||
);
|
||||
|
||||
// Same lifetime: one visit-steps initialization per profile opening (#425).
|
||||
const [initializeVisitStepsOnce] = useState(() =>
|
||||
createVisitStepsInitializer({ initializeVisitSteps, refresh, logError }),
|
||||
);
|
||||
|
||||
// Read at mount, then again at every route change.
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
|
|
@ -363,6 +454,15 @@ export function useOnboardingChecklist(): OnboardingValue {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const acknowledge = useCallback(async (key: ChecklistItemKey) => {
|
||||
try {
|
||||
await acknowledgeStep(key);
|
||||
dispatch({ type: "ACKNOWLEDGE", key });
|
||||
} catch (e) {
|
||||
logError("Onboarding: failed to acknowledge a step", errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checklist: Checklist | null = useMemo(
|
||||
() =>
|
||||
state.counters
|
||||
|
|
@ -370,11 +470,28 @@ export function useOnboardingChecklist(): OnboardingValue {
|
|||
importFolderSet: state.importFolderSet,
|
||||
budgetAllowed,
|
||||
skipped: state.skipped,
|
||||
acknowledged: state.acknowledged,
|
||||
visitStepsInitialized: state.visitStepsInitialized,
|
||||
})
|
||||
: null,
|
||||
[state.counters, state.importFolderSet, state.skipped, budgetAllowed],
|
||||
[
|
||||
state.counters,
|
||||
state.importFolderSet,
|
||||
state.skipped,
|
||||
state.acknowledged,
|
||||
state.visitStepsInitialized,
|
||||
budgetAllowed,
|
||||
],
|
||||
);
|
||||
|
||||
// The visit steps' one-time initialization (#425), after a successful load
|
||||
// with the Budget entitlement known; the initializer re-reads itself.
|
||||
const needsInitialization = needsVisitStepsInitialization(state, checklist, budgetReady);
|
||||
const acknowledgeVisitSteps = checklist !== null && shouldAcknowledgeVisitSteps(checklist);
|
||||
useEffect(() => {
|
||||
if (needsInitialization) void initializeVisitStepsOnce(acknowledgeVisitSteps);
|
||||
}, [needsInitialization, acknowledgeVisitSteps, initializeVisitStepsOnce]);
|
||||
|
||||
const { nextStep, cardPage, mode } = deriveOnboardingView({
|
||||
state,
|
||||
checklist,
|
||||
|
|
@ -396,6 +513,7 @@ export function useOnboardingChecklist(): OnboardingValue {
|
|||
counters: state.counters,
|
||||
hidden: state.hidden,
|
||||
budgetReady,
|
||||
visitStepsInitialized: state.visitStepsInitialized,
|
||||
nextStep,
|
||||
mode,
|
||||
cardPage,
|
||||
|
|
@ -403,6 +521,7 @@ export function useOnboardingChecklist(): OnboardingValue {
|
|||
show,
|
||||
skip,
|
||||
resume,
|
||||
acknowledge,
|
||||
refresh,
|
||||
}),
|
||||
[
|
||||
|
|
@ -411,6 +530,7 @@ export function useOnboardingChecklist(): OnboardingValue {
|
|||
state.counters,
|
||||
state.hidden,
|
||||
budgetReady,
|
||||
state.visitStepsInitialized,
|
||||
nextStep,
|
||||
mode,
|
||||
cardPage,
|
||||
|
|
@ -418,6 +538,7 @@ export function useOnboardingChecklist(): OnboardingValue {
|
|||
show,
|
||||
skip,
|
||||
resume,
|
||||
acknowledge,
|
||||
refresh,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
||||
const ctx = vi.hoisted(() => ({ pathname: "/" }));
|
||||
const ctx = vi.hoisted(() => ({ pathname: "/", budgetReady: true }));
|
||||
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) }));
|
||||
vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) }));
|
||||
vi.mock("./useEntitlement", () => ({
|
||||
useEntitlement: () => ({ allowed: true, ready: ctx.budgetReady }),
|
||||
}));
|
||||
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
|
||||
vi.mock("../services/onboardingService", () => ({
|
||||
getOnboardingSnapshot: vi.fn(),
|
||||
|
|
@ -22,6 +24,8 @@ vi.mock("../services/onboardingService", () => ({
|
|||
showChecklist: vi.fn(),
|
||||
skipStep: vi.fn(),
|
||||
resumeStep: vi.fn(),
|
||||
acknowledgeStep: vi.fn(),
|
||||
initializeVisitSteps: vi.fn(),
|
||||
}));
|
||||
|
||||
import { hookHarness } from "../test/hookHarness";
|
||||
|
|
@ -34,9 +38,11 @@ import {
|
|||
import { notifyDataChanged } from "../services/dataEvents";
|
||||
import { logError } from "../services/logService";
|
||||
import {
|
||||
acknowledgeStep,
|
||||
getOnboardingSnapshot,
|
||||
hideChecklist,
|
||||
initializeGuidedPath,
|
||||
initializeVisitSteps,
|
||||
resumeStep,
|
||||
showChecklist,
|
||||
skipStep,
|
||||
|
|
@ -50,6 +56,10 @@ const snapshot: OnboardingSnapshot = {
|
|||
importFolderSet: true,
|
||||
hidden: false,
|
||||
skipped: [],
|
||||
// Stopped after the import: the visit steps are acknowledged and initialized
|
||||
// (rule of the templates, #425).
|
||||
acknowledged: ["categories", "reports"],
|
||||
visitStepsInitialized: true,
|
||||
};
|
||||
|
||||
const ready: OnboardingChecklistState = [
|
||||
|
|
@ -76,9 +86,17 @@ const actionsOfType = (type: string) =>
|
|||
beforeEach(() => {
|
||||
harness.clear();
|
||||
ctx.pathname = "/";
|
||||
ctx.budgetReady = true;
|
||||
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
|
||||
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
|
||||
for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) {
|
||||
for (const write of [
|
||||
hideChecklist,
|
||||
showChecklist,
|
||||
skipStep,
|
||||
resumeStep,
|
||||
acknowledgeStep,
|
||||
initializeVisitSteps,
|
||||
]) {
|
||||
vi.mocked(write).mockReset().mockResolvedValue(undefined);
|
||||
}
|
||||
vi.mocked(logError).mockReset();
|
||||
|
|
@ -192,7 +210,7 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
|
|||
expect(value.nextStep?.key).toBe("manualCategory");
|
||||
expect(value.mode).toBe("banner");
|
||||
expect(value.cardPage).toBeNull();
|
||||
expect(value.checklist?.done).toBe(4);
|
||||
expect(value.checklist?.done).toBe(6);
|
||||
});
|
||||
|
||||
it("each action writes its preference first, then updates the state", async () => {
|
||||
|
|
@ -217,6 +235,32 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
|
|||
expect(vi.mocked(hideChecklist).mock.invocationCallOrder[0]).toBeLessThan(hideDispatch);
|
||||
});
|
||||
|
||||
it('"Got it" writes the acknowledgement first, then updates the state, keeping the card latch (#425)', async () => {
|
||||
mountWith({ ...ready, acknowledged: [], cardPage: "/import" });
|
||||
ctx.pathname = "/import";
|
||||
const value = render();
|
||||
await value.acknowledge("categories");
|
||||
expect(acknowledgeStep).toHaveBeenCalledWith("categories");
|
||||
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([
|
||||
{ type: "ACKNOWLEDGE", key: "categories" },
|
||||
]);
|
||||
expect(vi.mocked(acknowledgeStep).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
harness.dispatch.mock.invocationCallOrder[0],
|
||||
);
|
||||
const after = render();
|
||||
expect(after.cardPage).toBe("/import");
|
||||
expect(after.checklist?.items.find((i) => i.key === "categories")?.done).toBe(true);
|
||||
});
|
||||
|
||||
it('a failed "Got it" leaves the state alone and logs (#425)', async () => {
|
||||
mountWith({ ...ready, acknowledged: [] });
|
||||
const value = render();
|
||||
vi.mocked(acknowledgeStep).mockRejectedValueOnce(new Error("database is locked"));
|
||||
await value.acknowledge("reports");
|
||||
expect(harness.dispatch).not.toHaveBeenCalled();
|
||||
expect(logError).toHaveBeenCalledWith("Onboarding: failed to acknowledge a step", "database is locked");
|
||||
});
|
||||
|
||||
it("an action whose write fails leaves the state alone and logs", async () => {
|
||||
mountWith(ready);
|
||||
const value = render();
|
||||
|
|
@ -283,3 +327,85 @@ describe("useOnboardingChecklist — what survives a render (#409)", () => {
|
|||
expect(reports.mode).toBe("banner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useOnboardingChecklist — the visit steps' initialization (#425)", () => {
|
||||
const sixDone = { sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 20 };
|
||||
const notInitialized: OnboardingChecklistState = {
|
||||
...ready,
|
||||
counters: sixDone,
|
||||
acknowledged: [],
|
||||
visitStepsInitialized: false,
|
||||
};
|
||||
|
||||
it("runs once, after a successful load with the entitlement known, then re-reads", async () => {
|
||||
mountWith(notInitialized);
|
||||
ctx.pathname = "/reports";
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||
expect(initializeVisitSteps).toHaveBeenCalledWith(true);
|
||||
// The read of the mount plus the re-read of the initializer.
|
||||
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Later renders, the re-read not landed yet (flag still unset in state): no second run.
|
||||
harness.slots[0] = notInitialized;
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("an incomplete path initializes without acknowledging", async () => {
|
||||
mountWith({ ...notInitialized, counters: { ...sixDone, budgetEntries: 0 } });
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
expect(initializeVisitSteps).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("does not run before budgetReady, then runs once it is known", async () => {
|
||||
ctx.budgetReady = false;
|
||||
mountWith(notInitialized);
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
expect(initializeVisitSteps).not.toHaveBeenCalled();
|
||||
|
||||
ctx.budgetReady = true;
|
||||
harness.slots[0] = notInitialized;
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||
expect(initializeVisitSteps).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not run before the first successful load, nor once the flag is set", async () => {
|
||||
vi.mocked(getOnboardingSnapshot).mockReturnValue(new Promise(() => {}));
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
expect(initializeVisitSteps).not.toHaveBeenCalled();
|
||||
harness.unmount();
|
||||
harness.clear();
|
||||
vi.mocked(getOnboardingSnapshot).mockResolvedValue(snapshot);
|
||||
mountWith(ready);
|
||||
render();
|
||||
harness.commit();
|
||||
await flush();
|
||||
expect(initializeVisitSteps).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shares visitStepsInitialized; before it, a complete path shows no 'Next step' banner", () => {
|
||||
mountWith(notInitialized);
|
||||
ctx.pathname = "/reports";
|
||||
const value = render();
|
||||
expect(value.visitStepsInitialized).toBe(false);
|
||||
expect(value.nextStep).toBeNull();
|
||||
expect(value.mode).toBe("none");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
"card": {
|
||||
"stepOf": "Step {{n}} of {{total}}",
|
||||
"skip": "Skip this step",
|
||||
"acknowledge": "Got it",
|
||||
"done": "Done.",
|
||||
"next": "Next step: {{step}} →",
|
||||
"import": {
|
||||
|
|
@ -117,8 +118,10 @@
|
|||
"importFolder": "Choose an import folder",
|
||||
"source": "Set up an import source",
|
||||
"import": "Import a first file",
|
||||
"categories": "Browse categories and keywords",
|
||||
"manualCategory": "Categorize a transaction by hand",
|
||||
"budget": "Enter a first budget"
|
||||
"budget": "Enter a first budget",
|
||||
"reports": "Browse the reports"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -905,16 +908,18 @@
|
|||
"Choose an import folder — the folder where you will drop your statements, one subfolder per bank account",
|
||||
"Set up an import source — opening a subfolder for the first time detects the file format",
|
||||
"Import a first file — after reading the preview and its signed recap",
|
||||
"Browse categories and keywords — what keywords do, before categorizing",
|
||||
"Categorize a transaction by hand — the keyword seed does most of the work, a manual correction refines the rest",
|
||||
"Enter a first budget — Base edition and up; then track your progress through Reports"
|
||||
"Enter a first budget — Base edition and up; then track your progress through Reports",
|
||||
"Browse the reports — the period, the highlights and the report cards"
|
||||
],
|
||||
"subsections": [
|
||||
{
|
||||
"id": "getting-started-path",
|
||||
"title": "The getting-started path",
|
||||
"steps": [
|
||||
"The getting-started banner shows at the top of every page while the path is not hidden. It shows your progress, for instance \"Getting started 3/6\", and the next step, with a link to the page where it is done. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list",
|
||||
"\"Show the list\" unfolds the six steps under the banner and \"Close the list\" folds them; the list also folds when you change pages. Each step still to do leads to the page where it is done",
|
||||
"The getting-started banner shows at the top of every page while the path is not hidden. It shows your progress, for instance \"Getting started 3/8\", and the next step, with a link to the page where it is done. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list",
|
||||
"\"Show the list\" unfolds the eight steps under the banner and \"Close the list\" folds them; the list also folds when you change pages. Each step still to do leads to the page where it is done",
|
||||
"On the Import, Transactions and Budget pages, while the next step is done there, a getting-started card takes the place of the banner. It shows \"Step n of N\", says what to do and puts an outline around the control to use. The outline pulses three times, and not at all if your system asks for reduced motion. When you arrive by a \"Next step\" link, the keyboard focus goes to the card's title",
|
||||
"The card follows the step without you changing pages. On the Import page, it goes along with the wizard screen by screen. The link of the \"Categorize a transaction by hand\" step opens the Transactions page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. On the Budget page, the card first points at \"Expand all\", then at the current month's cell of the first category you can fill in; it never unfolds the grid for you. On the Transactions page, the card suggests right-clicking and “Add as keyword”; on the Budget page, it recalls that only last-level categories take an amount and what the Annual column does",
|
||||
"As soon as the step is done, the card stays, says \"Done.\" and offers the next step, with a link to its page. On the Import page, it moves from one step to the next without leaving the page and says \"Done.\" once the file is imported; never for an import that added no transaction: the report says why, and the step stays to do",
|
||||
|
|
@ -923,7 +928,7 @@
|
|||
"\"Hide\", in the banner or in the list, hides the banner and the list for this profile only. To bring them back, open Settings → Users and click \"Show the getting-started checklist\": the path reappears at the top of the pages. The button shows as soon as the path is hidden",
|
||||
"A profile that already had transactions before the guided path arrived has it hidden from its first opening, as if you had clicked \"Hide\"; Settings → Users shows it again. An empty or new profile keeps it"
|
||||
],
|
||||
"note": "Steps tick on their own, from your data and without changing pages: \"Import a first file\" expects a file that added at least one transaction, \"Categorize a transaction by hand\" expects a transaction whose category you picked yourself, and the automatic categorization of the import does not tick it. In the Free edition, the Budget step is locked and does not count: the path ends at 5/5."
|
||||
"note": "Steps tick on their own, from your data and without changing pages: \"Import a first file\" expects a file that added at least one transaction, \"Categorize a transaction by hand\" expects a transaction whose category you picked yourself, and the automatic categorization of the import does not tick it. The two visit steps, \"Browse categories and keywords\" and \"Browse the reports\", are checked with \"Got it\" on their card; a profile that had finished the path before they arrived finds them already done. In the Free edition, the Budget step is locked and does not count: the path ends at 7/7."
|
||||
}
|
||||
],
|
||||
"tips": [
|
||||
|
|
@ -968,7 +973,7 @@
|
|||
"Stacked bar chart of expenses by category and month",
|
||||
"Adjustable time period selector (default: year to date)",
|
||||
"Context menu (right-click) to hide a category or view its transactions",
|
||||
"Welcome card for a profile without transactions, with the getting-started checklist: six steps that tick themselves from your data, and a \"Hide\" button"
|
||||
"Welcome card for a profile without transactions, with the getting-started checklist: eight steps that tick themselves from your data, and a \"Hide\" button"
|
||||
],
|
||||
"steps": [
|
||||
"In a profile without transactions, the dashboard shows a welcome card and the getting-started checklist: create a profile (already ticked), choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget. Each unticked row leads to the page where it is done; the Budget box is marked \"Base and up\" in the Free edition and does not count towards the total",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
"card": {
|
||||
"stepOf": "Étape {{n}} sur {{total}}",
|
||||
"skip": "Passer cette étape",
|
||||
"acknowledge": "J'ai compris",
|
||||
"done": "Fait.",
|
||||
"next": "Étape suivante : {{step}} →",
|
||||
"import": {
|
||||
|
|
@ -117,8 +118,10 @@
|
|||
"importFolder": "Choisir un dossier d'import",
|
||||
"source": "Configurer une source d'import",
|
||||
"import": "Importer un premier fichier",
|
||||
"categories": "Parcourir les catégories et les mots-clés",
|
||||
"manualCategory": "Classer une transaction à la main",
|
||||
"budget": "Saisir un premier budget"
|
||||
"budget": "Saisir un premier budget",
|
||||
"reports": "Parcourir les rapports"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -905,16 +908,18 @@
|
|||
"Choisir un dossier d'import — le dossier où vous déposerez vos relevés, un sous-dossier par compte bancaire",
|
||||
"Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier",
|
||||
"Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé",
|
||||
"Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer",
|
||||
"Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste",
|
||||
"Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports"
|
||||
"Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports",
|
||||
"Parcourir les rapports — la période, les faits saillants et les cartes de rapports"
|
||||
],
|
||||
"subsections": [
|
||||
{
|
||||
"id": "getting-started-path",
|
||||
"title": "Le parcours de démarrage",
|
||||
"steps": [
|
||||
"Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/6 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste",
|
||||
"« Voir la liste » déplie les six étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire",
|
||||
"Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste",
|
||||
"« Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire",
|
||||
"Sur les pages Import, Transactions et Budget, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte",
|
||||
"La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel",
|
||||
"Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire",
|
||||
|
|
@ -923,7 +928,7 @@
|
|||
"« Masquer », dans le bandeau ou dans la liste, masque le bandeau et la liste pour ce profil seulement. Pour les retrouver, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages. Ce bouton est visible dès que le parcours est masqué",
|
||||
"Un profil qui contenait déjà des transactions avant l'arrivée du parcours guidé le voit masqué d'office à sa première ouverture, comme après « Masquer » ; Paramètres → Utilisateurs le réaffiche. Un profil vide ou neuf le garde"
|
||||
],
|
||||
"note": "Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 5/5."
|
||||
"note": "Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. Les deux étapes de visite, « Parcourir les catégories et les mots-clés » et « Parcourir les rapports », se cochent par « J'ai compris » sur leur carte ; un profil qui avait terminé le parcours avant leur arrivée les trouve déjà faites. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 7/7."
|
||||
}
|
||||
],
|
||||
"tips": [
|
||||
|
|
@ -968,7 +973,7 @@
|
|||
"Histogramme empilé des dépenses par catégorie et par mois",
|
||||
"Sélecteur de période ajustable (par défaut : année à ce jour)",
|
||||
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions",
|
||||
"Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : six étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »"
|
||||
"Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »"
|
||||
],
|
||||
"steps": [
|
||||
"Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total",
|
||||
|
|
|
|||
|
|
@ -78,13 +78,20 @@ function budget(overrides: Partial<BudgetState> = {}) {
|
|||
|
||||
/** The path latched on /budget (card mode), as the provider shares it. */
|
||||
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["categories", "reports"],
|
||||
});
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "card",
|
||||
cardPage: "/budget",
|
||||
|
|
@ -92,6 +99,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
|
|||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
acknowledge: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -132,7 +140,7 @@ describe("BudgetPage — the step card (#400)", () => {
|
|||
const html = render();
|
||||
expect(count(html, CARD)).toBe(1);
|
||||
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=6])");
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=7,total=8])");
|
||||
expect(html).toContain("t(onboarding.checklist.items.budget)");
|
||||
expect(html).toContain("t(onboarding.card.skip)");
|
||||
// The grid is there, below the card.
|
||||
|
|
|
|||
|
|
@ -63,13 +63,20 @@ const BUDGET_LEFT: OnboardingCounters = {
|
|||
|
||||
/** The path latched on /budget (card mode), as the provider shares it. */
|
||||
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["categories", "reports"],
|
||||
});
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "card",
|
||||
cardPage: "/budget",
|
||||
|
|
@ -77,6 +84,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
|
|||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
acknowledge: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -172,6 +180,8 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
|
|||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
skipped: ["source", "import", "manualCategory"],
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["categories", "reports"],
|
||||
});
|
||||
ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
|
||||
const budget = ctx.budget as { state: BudgetState };
|
||||
|
|
|
|||
|
|
@ -82,10 +82,16 @@ function path(overrides: Partial<OnboardingValue> & { transactions?: number } =
|
|||
const c = counters(transactions);
|
||||
return {
|
||||
status: "ready",
|
||||
checklist: computeChecklist(c, { importFolderSet: true, budgetAllowed: true }),
|
||||
checklist: computeChecklist(c, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: c.imports >= 1 ? ["categories", "reports"] : [],
|
||||
}),
|
||||
counters: c,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
nextStep: null,
|
||||
mode: "banner",
|
||||
cardPage: null,
|
||||
|
|
@ -93,6 +99,7 @@ function path(overrides: Partial<OnboardingValue> & { transactions?: number } =
|
|||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
acknowledge: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...rest,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export default function DashboardPage() {
|
|||
<DashboardEmptyState
|
||||
checklist={checklist}
|
||||
budgetReady={onboarding.budgetReady}
|
||||
visitStepsInitialized={onboarding.visitStepsInitialized}
|
||||
hidden={onboarding.hidden}
|
||||
onHide={onboarding.hide}
|
||||
onResume={onboarding.resume}
|
||||
|
|
|
|||
|
|
@ -82,13 +82,20 @@ function path(
|
|||
importFolderSet: boolean,
|
||||
overrides: Partial<OnboardingValue> = {},
|
||||
): OnboardingValue {
|
||||
const checklist = computeChecklist(counters, { importFolderSet, budgetAllowed: true });
|
||||
// A template stopped after the import has its visit steps acknowledged (#425).
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
|
||||
});
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "card",
|
||||
cardPage: "/import",
|
||||
|
|
@ -96,6 +103,7 @@ function path(
|
|||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
acknowledge: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -136,7 +144,7 @@ describe("ImportPage — the step card (#398)", () => {
|
|||
const html = render();
|
||||
expect(count(html, CARD)).toBe(1);
|
||||
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=2,total=6])");
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=2,total=8])");
|
||||
});
|
||||
|
||||
it("a new profile: 'Choose a folder' is the one target, and the sentence names it", () => {
|
||||
|
|
@ -155,7 +163,7 @@ describe("ImportPage — the step card (#398)", () => {
|
|||
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
|
||||
harness.onboarding = path(ZERO, true);
|
||||
const html = render();
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=6])");
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=8])");
|
||||
expect(sentence(html)).toBe(
|
||||
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
|
||||
);
|
||||
|
|
@ -217,7 +225,7 @@ describe("ImportPage — the step card (#398)", () => {
|
|||
const html = render();
|
||||
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
|
||||
expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/);
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=8])");
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -243,7 +251,7 @@ describe("ImportPage — the step card (#398)", () => {
|
|||
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||
harness.onboarding = path({ ...ZERO, sources: 1 }, true);
|
||||
const html = render();
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=8])");
|
||||
expect(html).not.toContain("t(onboarding.card.done)");
|
||||
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
|
||||
const target = highlighted(html);
|
||||
|
|
|
|||
|
|
@ -59,13 +59,20 @@ function path(
|
|||
overrides: Partial<OnboardingValue> = {},
|
||||
budgetAllowed = true,
|
||||
): OnboardingValue {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed });
|
||||
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["categories", "reports"],
|
||||
});
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "card",
|
||||
cardPage: "/transactions",
|
||||
|
|
@ -73,6 +80,7 @@ function path(
|
|||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
acknowledge: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -152,7 +160,7 @@ describe("TransactionsPage — the step card (#399)", () => {
|
|||
const html = render(GUIDED);
|
||||
expect(count(html, CARD)).toBe(1);
|
||||
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=6])");
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=8])");
|
||||
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
|
||||
});
|
||||
|
||||
|
|
@ -221,7 +229,7 @@ describe("TransactionsPage — the step card (#399)", () => {
|
|||
it("in the Free edition, the step checked ends the path", () => {
|
||||
harness.onboarding = path(CATEGORIZED, {}, false);
|
||||
const html = render();
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=5])");
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=7])");
|
||||
expect(html).toContain("t(onboarding.card.done)");
|
||||
expect(html).toContain("t(onboarding.banner.complete)");
|
||||
expect(html).not.toContain('href="/budget"');
|
||||
|
|
|
|||
|
|
@ -20,9 +20,13 @@ import { subscribeDataChanged } from "./dataEvents";
|
|||
import {
|
||||
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||
ONBOARDING_GUIDED_INITIALIZED_KEY,
|
||||
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
|
||||
acknowledgeStep,
|
||||
acknowledgedStepKey,
|
||||
getOnboardingSnapshot,
|
||||
hideChecklist,
|
||||
initializeGuidedPath,
|
||||
initializeVisitSteps,
|
||||
resumeStep,
|
||||
showChecklist,
|
||||
skipStep,
|
||||
|
|
@ -66,6 +70,11 @@ function makeFakeDb(options: FakeDbOptions = {}) {
|
|||
.filter(([key]) => /^onboarding.skipped./.test(key))
|
||||
.map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
if (sql.includes("LIKE 'onboarding_acknowledged_%'")) {
|
||||
return Object.entries(prefs)
|
||||
.filter(([key]) => /^onboarding.acknowledged./.test(key))
|
||||
.map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
if (sql.includes("FROM user_preferences WHERE key = $1")) {
|
||||
const key = String(params?.[0]);
|
||||
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
|
||||
|
|
@ -125,10 +134,10 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("runs the five sub-counts in one query, plus the two preference reads and the skipped steps", async () => {
|
||||
it("runs the five sub-counts in one query, plus the three preference reads and the skipped and acknowledged steps", async () => {
|
||||
const { db } = useFakeDb();
|
||||
await getOnboardingSnapshot();
|
||||
expect(db.select).toHaveBeenCalledTimes(4);
|
||||
expect(db.select).toHaveBeenCalledTimes(6);
|
||||
const sqls = db.select.mock.calls.map((c) => String(c[0]));
|
||||
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
|
||||
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
|
||||
|
|
@ -138,9 +147,14 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
|||
.filter((c) => String(c[0]).includes("WHERE key = $1"))
|
||||
.map((c) => (c[1] as unknown[])[0]);
|
||||
expect(prefKeys).toEqual(
|
||||
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
|
||||
expect.arrayContaining([
|
||||
"import_folder",
|
||||
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
|
||||
]),
|
||||
);
|
||||
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
|
||||
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_acknowledged_%'"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The tests run without SQLite, so the rule is pinned on the query itself.
|
||||
|
|
@ -208,6 +222,40 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("reports no acknowledged step and visitStepsInitialized=false without preferences (#425)", async () => {
|
||||
useFakeDb();
|
||||
const snapshot = await getOnboardingSnapshot();
|
||||
expect(snapshot.acknowledged).toEqual([]);
|
||||
expect(snapshot.visitStepsInitialized).toBe(false);
|
||||
});
|
||||
|
||||
it('reports visitStepsInitialized=true only for the value "1" (#425)', async () => {
|
||||
useFakeDb({ prefs: { [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1" } });
|
||||
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(true);
|
||||
useFakeDb({ prefs: { [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "true" } });
|
||||
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the acknowledged steps in checklist order, only the visit keys at \"1\" (#425)", async () => {
|
||||
useFakeDb({
|
||||
prefs: {
|
||||
[acknowledgedStepKey("reports")]: "1",
|
||||
[acknowledgedStepKey("categories")]: "1",
|
||||
[acknowledgedStepKey("budget")]: "1",
|
||||
onboarding_acknowledged_dragons: "1",
|
||||
onboarding_acknowledged_reports_extra: "1",
|
||||
},
|
||||
});
|
||||
expect((await getOnboardingSnapshot()).acknowledged).toEqual(["categories", "reports"]);
|
||||
useFakeDb({
|
||||
prefs: {
|
||||
[acknowledgedStepKey("categories")]: "0",
|
||||
[acknowledgedStepKey("reports")]: "1",
|
||||
},
|
||||
});
|
||||
expect((await getOnboardingSnapshot()).acknowledged).toEqual(["reports"]);
|
||||
});
|
||||
|
||||
it("reads the skipped steps in checklist order", async () => {
|
||||
useFakeDb({
|
||||
prefs: {
|
||||
|
|
@ -218,7 +266,7 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
|||
expect((await getOnboardingSnapshot()).skipped).toEqual(["source", "budget"]);
|
||||
});
|
||||
|
||||
it("keeps only the six keys: an unknown step, or what LIKE's `_` wildcard lets through, is ignored", async () => {
|
||||
it("keeps only the eight keys: an unknown step, or what LIKE's `_` wildcard lets through, is ignored", async () => {
|
||||
useFakeDb({
|
||||
prefs: {
|
||||
onboarding_skipped_dragons: "1",
|
||||
|
|
@ -468,3 +516,97 @@ describe("skipStep / resumeStep (#396, D3)", () => {
|
|||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acknowledgeStep / initializeVisitSteps (#425)", () => {
|
||||
it('acknowledgeStep writes "1" under onboarding_acknowledged_<key>, without reading first', async () => {
|
||||
const fake = useFakeDb();
|
||||
await acknowledgeStep("categories");
|
||||
expect(fake.db.select).not.toHaveBeenCalled();
|
||||
expect(fake.db.execute).toHaveBeenCalledTimes(1);
|
||||
expect(fake.db.execute.mock.calls[0][1]).toEqual(["onboarding_acknowledged_categories", "1"]);
|
||||
expect(fake.prefs()).toEqual({ onboarding_acknowledged_categories: "1" });
|
||||
});
|
||||
|
||||
it("acknowledgeStep round-trips through the snapshot, as a done step, not a skipped one", async () => {
|
||||
useFakeDb();
|
||||
await acknowledgeStep("reports");
|
||||
const snapshot = await getOnboardingSnapshot();
|
||||
expect(snapshot.acknowledged).toEqual(["reports"]);
|
||||
expect(snapshot.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("initializeVisitSteps(true) writes both acknowledgements, then the flag", async () => {
|
||||
const fake = useFakeDb();
|
||||
await initializeVisitSteps(true);
|
||||
expect(fake.db.execute.mock.calls.map((c) => (c[1] as unknown[])[0])).toEqual([
|
||||
"onboarding_acknowledged_categories",
|
||||
"onboarding_acknowledged_reports",
|
||||
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
|
||||
]);
|
||||
expect(fake.prefs()).toEqual({
|
||||
onboarding_acknowledged_categories: "1",
|
||||
onboarding_acknowledged_reports: "1",
|
||||
[ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1",
|
||||
});
|
||||
const snapshot = await getOnboardingSnapshot();
|
||||
expect(snapshot.acknowledged).toEqual(["categories", "reports"]);
|
||||
expect(snapshot.visitStepsInitialized).toBe(true);
|
||||
});
|
||||
|
||||
it("initializeVisitSteps(false) writes the flag only", async () => {
|
||||
const fake = useFakeDb();
|
||||
await initializeVisitSteps(false);
|
||||
expect(fake.prefs()).toEqual({ [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1" });
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs without any transaction: no withTransaction, no BEGIN (D16)", async () => {
|
||||
const fake = useFakeDb();
|
||||
await initializeVisitSteps(true);
|
||||
await initializeVisitSteps(false);
|
||||
expect(withTransaction).not.toHaveBeenCalled();
|
||||
expect(fake.statements.filter((sql) => /^(BEGIN|COMMIT|ROLLBACK)/.test(sql))).toEqual([]);
|
||||
});
|
||||
|
||||
it("a failed acknowledgement leaves the flag unset, so the next opening computes again", async () => {
|
||||
const fake = useFakeDb({
|
||||
failOn: (_sql, params) => params[0] === "onboarding_acknowledged_reports",
|
||||
});
|
||||
await expect(initializeVisitSteps(true)).rejects.toThrow("database is locked");
|
||||
expect(fake.prefs()).toEqual({ onboarding_acknowledged_categories: "1" });
|
||||
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent: run again after a stop, it ends in the same state", async () => {
|
||||
const fake = useFakeDb({ prefs: { onboarding_acknowledged_categories: "1" } });
|
||||
await initializeVisitSteps(true);
|
||||
await initializeVisitSteps(true);
|
||||
expect(fake.prefs()).toEqual({
|
||||
onboarding_acknowledged_categories: "1",
|
||||
onboarding_acknowledged_reports: "1",
|
||||
[ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("logs a fixed line only when it marks the visit steps done", async () => {
|
||||
useFakeDb();
|
||||
await initializeVisitSteps(true);
|
||||
expect(vi.mocked(logInfo).mock.calls.map((c) => c[0])).toContain(
|
||||
"Onboarding: the path was already complete, visit steps marked done",
|
||||
);
|
||||
});
|
||||
|
||||
it("neither announces on the data channel", async () => {
|
||||
useFakeDb();
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeDataChanged(listener);
|
||||
try {
|
||||
await acknowledgeStep("categories");
|
||||
await initializeVisitSteps(true);
|
||||
await initializeVisitSteps(false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { logInfo } from "./logService";
|
||||
import {
|
||||
CHECKLIST_ITEM_KEYS,
|
||||
VISIT_STEP_KEYS,
|
||||
type ChecklistItemKey,
|
||||
type OnboardingCounters,
|
||||
} from "../shared/types";
|
||||
|
|
@ -17,9 +18,11 @@ import {
|
|||
*
|
||||
* `getOnboardingSnapshot` reads everything the path needs in one round of
|
||||
* parallel reads, and writes nothing: the counters, the import folder, the
|
||||
* "hidden" preference and the skipped steps. The writes are separate, and the
|
||||
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
|
||||
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is
|
||||
* "hidden" preference, the skipped and acknowledged steps, and the visit-steps
|
||||
* flag. The writes are separate, and the onboarding provider is their only
|
||||
* caller: `initializeGuidedPath` (rule D9), `initializeVisitSteps` (#425),
|
||||
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`,
|
||||
* `acknowledgeStep`. None of them is
|
||||
* announced on the data channel: the provider updates its own state. No UI
|
||||
* lives here; `computeChecklist` turns the snapshot into rows and
|
||||
* `useOnboardingChecklist` holds the state.
|
||||
|
|
@ -36,14 +39,26 @@ export function skippedStepKey(key: ChecklistItemKey): string {
|
|||
return `onboarding_skipped_${key}`;
|
||||
}
|
||||
|
||||
/** `user_preferences` key of a visit step done by "Got it" (#425): `onboarding_acknowledged_<key>`, value "1". */
|
||||
export function acknowledgedStepKey(key: ChecklistItemKey): string {
|
||||
return `onboarding_acknowledged_${key}`;
|
||||
}
|
||||
|
||||
/** `user_preferences` key set to "1" once the visit steps were initialized for the profile (#425). */
|
||||
export const ONBOARDING_VISIT_STEPS_INITIALIZED_KEY = "onboarding_visit_steps_initialized";
|
||||
|
||||
export interface OnboardingSnapshot {
|
||||
counters: OnboardingCounters;
|
||||
/** True when an import folder has been chosen. */
|
||||
importFolderSet: boolean;
|
||||
/** True when the path is hidden (by the user, or by rule D9). */
|
||||
hidden: boolean;
|
||||
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
|
||||
/** Skipped steps, in checklist order: only the eight keys, only the value "1". */
|
||||
skipped: ChecklistItemKey[];
|
||||
/** Visit steps done by "Got it" (#425), in checklist order: only the visit keys, only the value "1". */
|
||||
acknowledged: ChecklistItemKey[];
|
||||
/** True once `initializeVisitSteps` ran for the profile (only the value "1" counts). */
|
||||
visitStepsInitialized: boolean;
|
||||
}
|
||||
|
||||
interface CountersRow {
|
||||
|
|
@ -74,6 +89,10 @@ const COUNTERS_SQL = `
|
|||
const SKIPPED_SQL =
|
||||
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
|
||||
|
||||
// Same caveat: `readAcknowledgedSteps` keeps only the exact keys of the visit steps.
|
||||
const ACKNOWLEDGED_SQL =
|
||||
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_acknowledged_%'";
|
||||
|
||||
const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`;
|
||||
|
|
@ -99,23 +118,37 @@ async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
|
|||
return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(key)));
|
||||
}
|
||||
|
||||
/** Acknowledged visit steps, read in one query and kept only for a visit key holding "1". */
|
||||
async function readAcknowledgedSteps(): Promise<ChecklistItemKey[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select<Array<{ key: string; value: string }>>(ACKNOWLEDGED_SQL);
|
||||
const marked = new Set(rows.filter((row) => row.value === "1").map((row) => row.key));
|
||||
return VISIT_STEP_KEYS.filter((key) => marked.has(acknowledgedStepKey(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the counters, the import folder, the hidden flag and the skipped steps
|
||||
* in parallel. A pure read: it never writes (rule D9 lives in
|
||||
* `initializeGuidedPath`).
|
||||
* Reads the counters, the import folder, the hidden flag, the skipped and
|
||||
* acknowledged steps and the visit-steps flag in parallel. A pure read: it
|
||||
* never writes (rule D9 lives in `initializeGuidedPath`, the visit steps'
|
||||
* initialization in `initializeVisitSteps`).
|
||||
*/
|
||||
export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
|
||||
const [counters, importFolder, hiddenPref, skipped] = await Promise.all([
|
||||
const [counters, importFolder, hiddenPref, skipped, acknowledged, visitFlag] =
|
||||
await Promise.all([
|
||||
readCounters(),
|
||||
getImportFolder(),
|
||||
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
|
||||
readSkippedSteps(),
|
||||
readAcknowledgedSteps(),
|
||||
getPreference(ONBOARDING_VISIT_STEPS_INITIALIZED_KEY),
|
||||
]);
|
||||
return {
|
||||
counters,
|
||||
importFolderSet: importFolder !== null && importFolder !== "",
|
||||
hidden: hiddenPref === "1",
|
||||
skipped,
|
||||
acknowledged,
|
||||
visitStepsInitialized: visitFlag === "1",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -201,3 +234,34 @@ export async function resumeStep(key: ChecklistItemKey): Promise<void> {
|
|||
await deletePreference(skippedStepKey(key));
|
||||
logInfo(`Onboarding: step ${key} resumed`);
|
||||
}
|
||||
|
||||
/** Marks a visit step done ("Got it", #425). Idempotent: an UPSERT, no read before the write. */
|
||||
export async function acknowledgeStep(key: ChecklistItemKey): Promise<void> {
|
||||
await setPreference(acknowledgedStepKey(key), "1");
|
||||
logInfo(`Onboarding: step ${key} acknowledged`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The visit steps' initialization (#425), once per profile: the provider calls
|
||||
* it after the first successful load where the Budget entitlement is known and
|
||||
* the flag is unset, with `acknowledge` = `shouldAcknowledgeVisitSteps`, then
|
||||
* re-reads. A profile whose other steps were all done or skipped gets both
|
||||
* visit steps acknowledged, so it keeps "Getting started complete".
|
||||
*
|
||||
* Deliberately without a transaction (D16): under the strict lock of `db.ts`
|
||||
* (#408), a service call made from a transaction's callback would wait behind
|
||||
* that very lock, and every write here is idempotent anyway. The two
|
||||
* acknowledgements come first, then the flag: a stop between two writes leaves
|
||||
* the flag unset, and the next opening makes the same computation again.
|
||||
* Not announced on the data channel.
|
||||
*/
|
||||
export async function initializeVisitSteps(acknowledge: boolean): Promise<void> {
|
||||
if (acknowledge) {
|
||||
await acknowledgeStep("categories");
|
||||
await acknowledgeStep("reports");
|
||||
}
|
||||
await setPreference(ONBOARDING_VISIT_STEPS_INITIALIZED_KEY, "1");
|
||||
if (acknowledge) {
|
||||
logInfo("Onboarding: the path was already complete, visit steps marked done");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -982,19 +982,30 @@ export type ChecklistItemKey =
|
|||
| "importFolder"
|
||||
| "source"
|
||||
| "import"
|
||||
| "categories"
|
||||
| "manualCategory"
|
||||
| "budget";
|
||||
| "budget"
|
||||
| "reports";
|
||||
|
||||
/** The six checklist keys, in checklist order (#396: the only valid skipped-step keys). */
|
||||
/** The eight checklist keys, in checklist order (#396: the only valid skipped-step keys; #425: eight). */
|
||||
export const CHECKLIST_ITEM_KEYS: readonly ChecklistItemKey[] = [
|
||||
"profile",
|
||||
"importFolder",
|
||||
"source",
|
||||
"import",
|
||||
"categories",
|
||||
"manualCategory",
|
||||
"budget",
|
||||
"reports",
|
||||
];
|
||||
|
||||
/**
|
||||
* The visit steps (#425): nothing in the database counts them, they are done
|
||||
* by "Got it" on their card (`onboarding_acknowledged_<key>`), and the only
|
||||
* valid acknowledged-step keys.
|
||||
*/
|
||||
export const VISIT_STEP_KEYS: readonly ChecklistItemKey[] = ["categories", "reports"];
|
||||
|
||||
/** One row of the getting-started checklist. `to` is the route the row links to. */
|
||||
export interface ChecklistItem {
|
||||
key: ChecklistItemKey;
|
||||
|
|
@ -1002,7 +1013,10 @@ export interface ChecklistItem {
|
|||
done: boolean;
|
||||
/** The user skipped the step (#396, D3). A row can be done and skipped. */
|
||||
skipped: boolean;
|
||||
/** True when the feature behind the row is not part of the current edition. */
|
||||
/**
|
||||
* True when the feature behind the row is not part of the current edition,
|
||||
* or for a visit step (#425) until the profile's visit steps are initialized.
|
||||
*/
|
||||
locked: boolean;
|
||||
to: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeChecklist } from "./onboardingChecklist";
|
||||
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||
import { computeChecklist, shouldAcknowledgeVisitSteps } from "./onboardingChecklist";
|
||||
import {
|
||||
CHECKLIST_ITEM_KEYS,
|
||||
VISIT_STEP_KEYS,
|
||||
type ChecklistItemKey,
|
||||
type OnboardingCounters,
|
||||
} from "../shared/types";
|
||||
|
||||
const zero: OnboardingCounters = {
|
||||
sources: 0,
|
||||
|
|
@ -10,7 +15,9 @@ const zero: OnboardingCounters = {
|
|||
transactions: 0,
|
||||
};
|
||||
|
||||
const open = { importFolderSet: false, budgetAllowed: true };
|
||||
const open = { importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true };
|
||||
/** Both visit steps acknowledged (#425): the old six-step fixtures, complete again at 8/8. */
|
||||
const visited: ChecklistItemKey[] = ["categories", "reports"];
|
||||
|
||||
function keys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] {
|
||||
return computeChecklist(counters, opts).items.map((i) => i.key);
|
||||
|
|
@ -23,22 +30,30 @@ function doneKeys(counters: OnboardingCounters, opts = open): ChecklistItemKey[]
|
|||
}
|
||||
|
||||
describe("computeChecklist — rows (#351)", () => {
|
||||
it("lists the six rows in order", () => {
|
||||
it("lists the eight rows in order (#425)", () => {
|
||||
expect(keys(zero)).toEqual([
|
||||
"profile",
|
||||
"importFolder",
|
||||
"source",
|
||||
"import",
|
||||
"categories",
|
||||
"manualCategory",
|
||||
"budget",
|
||||
"reports",
|
||||
]);
|
||||
});
|
||||
|
||||
it("zero counters → only profile is done (1/6)", () => {
|
||||
it("CHECKLIST_ITEM_KEYS lists the rows in their order, VISIT_STEP_KEYS the two visit steps", () => {
|
||||
// The reducer keeps skipped and acknowledged steps in this order (`withStep`).
|
||||
expect(CHECKLIST_ITEM_KEYS).toEqual(keys(zero));
|
||||
expect(VISIT_STEP_KEYS).toEqual(["categories", "reports"]);
|
||||
});
|
||||
|
||||
it("zero counters → only profile is done (1/8)", () => {
|
||||
const list = computeChecklist(zero, open);
|
||||
expect(doneKeys(zero)).toEqual(["profile"]);
|
||||
expect(list.done).toBe(1);
|
||||
expect(list.total).toBe(6);
|
||||
expect(list.total).toBe(8);
|
||||
expect(list.complete).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -79,8 +94,10 @@ describe("computeChecklist — rows (#351)", () => {
|
|||
importFolder: "/import",
|
||||
source: "/import",
|
||||
import: "/import",
|
||||
categories: "/categories",
|
||||
manualCategory: "/transactions",
|
||||
budget: "/budget",
|
||||
reports: "/reports",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -90,11 +107,11 @@ describe("computeChecklist — rows (#351)", () => {
|
|||
});
|
||||
|
||||
describe("computeChecklist — budget gating (#351)", () => {
|
||||
const locked = { importFolderSet: false, budgetAllowed: false };
|
||||
const locked = { importFolderSet: false, budgetAllowed: false, visitStepsInitialized: true };
|
||||
|
||||
it("budgetAllowed=false → total=5 and the budget row is locked", () => {
|
||||
it("budgetAllowed=false → total=7 and the budget row is locked", () => {
|
||||
const list = computeChecklist(zero, locked);
|
||||
expect(list.total).toBe(5);
|
||||
expect(list.total).toBe(7);
|
||||
const budget = list.items.find((i) => i.key === "budget")!;
|
||||
expect(budget.locked).toBe(true);
|
||||
expect(budget.to).toBe("/settings/users");
|
||||
|
|
@ -106,27 +123,27 @@ describe("computeChecklist — budget gating (#351)", () => {
|
|||
expect(list.done).toBe(1);
|
||||
});
|
||||
|
||||
it("complete is true at 5/5 when Budget is locked", () => {
|
||||
it("complete is true at 7/7 when Budget is locked", () => {
|
||||
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||
const list = computeChecklist(all, { ...locked, importFolderSet: true });
|
||||
expect(list.done).toBe(5);
|
||||
expect(list.total).toBe(5);
|
||||
const list = computeChecklist(all, { ...locked, importFolderSet: true, acknowledged: visited });
|
||||
expect(list.done).toBe(7);
|
||||
expect(list.total).toBe(7);
|
||||
expect(list.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("complete is false at 5/6 when Budget is open but empty", () => {
|
||||
it("complete is false at 7/8 when Budget is open but empty", () => {
|
||||
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||
const list = computeChecklist(all, { ...open, importFolderSet: true });
|
||||
expect(list.done).toBe(5);
|
||||
expect(list.total).toBe(6);
|
||||
const list = computeChecklist(all, { ...open, importFolderSet: true, acknowledged: visited });
|
||||
expect(list.done).toBe(7);
|
||||
expect(list.total).toBe(8);
|
||||
expect(list.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("complete is true at 6/6 when Budget is open", () => {
|
||||
it("complete is true at 8/8 when Budget is open", () => {
|
||||
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
|
||||
const list = computeChecklist(all, { ...open, importFolderSet: true });
|
||||
expect(list.done).toBe(6);
|
||||
expect(list.total).toBe(6);
|
||||
const list = computeChecklist(all, { ...open, importFolderSet: true, acknowledged: visited });
|
||||
expect(list.done).toBe(8);
|
||||
expect(list.total).toBe(8);
|
||||
expect(list.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -147,21 +164,31 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
|
|||
expect(row.skipped).toBe(true);
|
||||
expect(row.done).toBe(false);
|
||||
expect(list.done).toBe(2); // profile + the skipped row
|
||||
expect(list.total).toBe(6);
|
||||
expect(list.total).toBe(8);
|
||||
});
|
||||
|
||||
it("complete with done and skipped rows", () => {
|
||||
const list = computeChecklist(fiveDone, { ...open, importFolderSet: true, skipped: ["budget"] });
|
||||
expect(list.done).toBe(6);
|
||||
expect(list.total).toBe(6);
|
||||
const list = computeChecklist(fiveDone, {
|
||||
...open,
|
||||
importFolderSet: true,
|
||||
skipped: ["budget"],
|
||||
acknowledged: visited,
|
||||
});
|
||||
expect(list.done).toBe(8);
|
||||
expect(list.total).toBe(8);
|
||||
expect(list.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("a locked row stays out of the total, even skipped", () => {
|
||||
const list = computeChecklist(zero, { importFolderSet: false, budgetAllowed: false, skipped: ["budget"] });
|
||||
const list = computeChecklist(zero, {
|
||||
importFolderSet: false,
|
||||
budgetAllowed: false,
|
||||
visitStepsInitialized: true,
|
||||
skipped: ["budget"],
|
||||
});
|
||||
const budget = list.items.find((i) => i.key === "budget")!;
|
||||
expect(budget.locked).toBe(true);
|
||||
expect(list.total).toBe(5);
|
||||
expect(list.total).toBe(7);
|
||||
expect(list.done).toBe(1);
|
||||
expect(list.complete).toBe(false);
|
||||
});
|
||||
|
|
@ -171,12 +198,13 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
|
|||
...open,
|
||||
importFolderSet: true,
|
||||
skipped: ["source", "import", "budget"],
|
||||
acknowledged: visited,
|
||||
});
|
||||
const source = list.items.find((i) => i.key === "source")!;
|
||||
expect(source.done).toBe(true);
|
||||
expect(source.skipped).toBe(true);
|
||||
expect(list.done).toBe(6);
|
||||
expect(list.total).toBe(6);
|
||||
expect(list.done).toBe(8);
|
||||
expect(list.total).toBe(8);
|
||||
});
|
||||
|
||||
it("skipping does not change the rows' links", () => {
|
||||
|
|
@ -186,3 +214,115 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
|
|||
expect(to).toEqual(computeChecklist(zero, open).items.map((i) => i.to));
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeChecklist — visit steps (#425)", () => {
|
||||
const sixDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
|
||||
const row = (list: ReturnType<typeof computeChecklist>, key: ChecklistItemKey) =>
|
||||
list.items.find((i) => i.key === key)!;
|
||||
|
||||
it("a visit step is to do until acknowledged; nothing in the counters marks it done", () => {
|
||||
const list = computeChecklist(
|
||||
{ ...sixDone, transactions: 50 },
|
||||
{ ...open, importFolderSet: true },
|
||||
);
|
||||
expect(row(list, "categories").done).toBe(false);
|
||||
expect(row(list, "reports").done).toBe(false);
|
||||
expect(list.done).toBe(6);
|
||||
expect(list.total).toBe(8);
|
||||
expect(list.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("an acknowledged step is done, not skipped", () => {
|
||||
const list = computeChecklist(zero, { ...open, acknowledged: ["categories"] });
|
||||
expect(row(list, "categories").done).toBe(true);
|
||||
expect(row(list, "categories").skipped).toBe(false);
|
||||
expect(row(list, "reports").done).toBe(false);
|
||||
expect(list.done).toBe(2);
|
||||
});
|
||||
|
||||
it("only a visit step can be acknowledged: another key in the option changes nothing", () => {
|
||||
const list = computeChecklist(zero, { ...open, acknowledged: ["budget", "source"] });
|
||||
expect(row(list, "budget").done).toBe(false);
|
||||
expect(row(list, "source").done).toBe(false);
|
||||
expect(list.done).toBe(1);
|
||||
});
|
||||
|
||||
it("a visit step can be skipped", () => {
|
||||
const list = computeChecklist(zero, { ...open, skipped: ["reports"] });
|
||||
expect(row(list, "reports").skipped).toBe(true);
|
||||
expect(row(list, "reports").done).toBe(false);
|
||||
expect(list.done).toBe(2);
|
||||
});
|
||||
|
||||
it("before the initialization, both visit rows are locked: out of the total and of `complete`, links kept", () => {
|
||||
const list = computeChecklist(sixDone, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: false,
|
||||
});
|
||||
expect(row(list, "categories").locked).toBe(true);
|
||||
expect(row(list, "reports").locked).toBe(true);
|
||||
expect(row(list, "categories").to).toBe("/categories");
|
||||
expect(row(list, "reports").to).toBe("/reports");
|
||||
expect(list.items.filter((i) => i.locked).map((i) => i.key)).toEqual(["categories", "reports"]);
|
||||
expect(list.total).toBe(6);
|
||||
expect(list.done).toBe(6);
|
||||
expect(list.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("before the initialization, a visit row is out of the total even when acknowledged or skipped", () => {
|
||||
const list = computeChecklist(zero, {
|
||||
importFolderSet: false,
|
||||
budgetAllowed: false,
|
||||
visitStepsInitialized: false,
|
||||
acknowledged: ["categories"],
|
||||
skipped: ["reports"],
|
||||
});
|
||||
expect(list.total).toBe(5);
|
||||
expect(list.done).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAcknowledgeVisitSteps (#425)", () => {
|
||||
const sixDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
|
||||
const before = { importFolderSet: true, budgetAllowed: true, visitStepsInitialized: false };
|
||||
|
||||
it("true when the six other rows are done (Base+)", () => {
|
||||
expect(shouldAcknowledgeVisitSteps(computeChecklist(sixDone, before))).toBe(true);
|
||||
});
|
||||
|
||||
it("true when the six other rows are done or skipped", () => {
|
||||
const counters = { ...zero, sources: 1, imports: 1 };
|
||||
const list = computeChecklist(counters, { ...before, skipped: ["manualCategory", "budget"] });
|
||||
expect(shouldAcknowledgeVisitSteps(list)).toBe(true);
|
||||
});
|
||||
|
||||
it("true in the Free edition with Budget locked and the five others done", () => {
|
||||
const counters = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||
const list = computeChecklist(counters, { ...before, budgetAllowed: false });
|
||||
expect(shouldAcknowledgeVisitSteps(list)).toBe(true);
|
||||
});
|
||||
|
||||
it("false while one row is left", () => {
|
||||
const counters = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||
expect(shouldAcknowledgeVisitSteps(computeChecklist(counters, before))).toBe(false);
|
||||
expect(
|
||||
shouldAcknowledgeVisitSteps(computeChecklist(sixDone, { ...before, importFolderSet: false })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("the visit rows do not enter the computation, locked or not", () => {
|
||||
// Unlocked and to do: still true.
|
||||
expect(
|
||||
shouldAcknowledgeVisitSteps(computeChecklist(sixDone, { ...before, visitStepsInitialized: true })),
|
||||
).toBe(true);
|
||||
// Acknowledged or skipped: does not make an incomplete path complete.
|
||||
const list = computeChecklist(zero, {
|
||||
...before,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["categories"],
|
||||
skipped: ["reports"],
|
||||
});
|
||||
expect(shouldAcknowledgeVisitSteps(list)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,24 @@
|
|||
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||
import {
|
||||
VISIT_STEP_KEYS,
|
||||
type ChecklistItem,
|
||||
type ChecklistItemKey,
|
||||
type OnboardingCounters,
|
||||
} from "../shared/types";
|
||||
|
||||
/**
|
||||
* Pure computation of the getting-started checklist (#351).
|
||||
* Pure computation of the getting-started checklist (#351, #425).
|
||||
*
|
||||
* Six rows in a fixed order. The `profile` row is always done (the checklist
|
||||
* Eight rows in a fixed order. The `profile` row is always done (the checklist
|
||||
* is only reachable from an open profile). The `budget` row is locked when the
|
||||
* edition does not include Budget; a locked row is excluded from `total` and
|
||||
* from `complete`, and links to the Users settings (license card) instead.
|
||||
*
|
||||
* The two visit steps, `categories` and `reports` (#425), are done when the
|
||||
* user acknowledged them ("Got it"): nothing in the database counts them.
|
||||
* Until the profile's visit steps are initialized (`visitStepsInitialized`),
|
||||
* they are locked the same way, so a profile whose path was complete does not
|
||||
* flash a "Next step" before they are marked done; their `to` stays the page.
|
||||
*
|
||||
* A row can also be skipped by the user (#396, D3). A skipped row counts like a
|
||||
* done one, once even when it is both, unless it is locked: a locked row stays
|
||||
* out of the total whatever its state.
|
||||
|
|
@ -20,13 +31,17 @@ export interface ChecklistOptions {
|
|||
budgetAllowed: boolean;
|
||||
/** Steps the user skipped (#396). None when omitted. */
|
||||
skipped?: readonly ChecklistItemKey[];
|
||||
/** Visit steps the user acknowledged with "Got it" (#425). None when omitted. */
|
||||
acknowledged?: readonly ChecklistItemKey[];
|
||||
/** The profile's visit steps are initialized (#425): until then, they are locked. */
|
||||
visitStepsInitialized: boolean;
|
||||
}
|
||||
|
||||
export interface Checklist {
|
||||
items: ChecklistItem[];
|
||||
/** Number of unlocked rows that are done or skipped. */
|
||||
done: number;
|
||||
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
||||
/** Number of unlocked rows (8, or 7 when Budget is locked; 2 fewer before the visit steps are initialized). */
|
||||
total: number;
|
||||
/** True when every unlocked row is done or skipped. */
|
||||
complete: boolean;
|
||||
|
|
@ -37,7 +52,9 @@ export function computeChecklist(
|
|||
options: ChecklistOptions
|
||||
): Checklist {
|
||||
const budgetLocked = !options.budgetAllowed;
|
||||
const visitLocked = !options.visitStepsInitialized;
|
||||
const skipped = new Set(options.skipped ?? []);
|
||||
const acknowledged = new Set(options.acknowledged ?? []);
|
||||
const row = (
|
||||
key: ChecklistItemKey,
|
||||
done: boolean,
|
||||
|
|
@ -52,6 +69,7 @@ export function computeChecklist(
|
|||
row("importFolder", options.importFolderSet, "/import"),
|
||||
row("source", counters.sources >= 1, "/import"),
|
||||
row("import", counters.imports >= 1, "/import"),
|
||||
row("categories", acknowledged.has("categories"), "/categories", visitLocked),
|
||||
row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
|
||||
row(
|
||||
"budget",
|
||||
|
|
@ -59,6 +77,7 @@ export function computeChecklist(
|
|||
budgetLocked ? "/settings/users" : "/budget",
|
||||
budgetLocked,
|
||||
),
|
||||
row("reports", acknowledged.has("reports"), "/reports", visitLocked),
|
||||
];
|
||||
|
||||
const unlocked = items.filter((item) => !item.locked);
|
||||
|
|
@ -67,3 +86,15 @@ export function computeChecklist(
|
|||
|
||||
return { items, done, total, complete: done === total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the one-time initialization of the visit steps (#425) marks them
|
||||
* done: true when every unlocked row other than the visit steps is done or
|
||||
* skipped, so a profile that had finished the path before they existed keeps
|
||||
* "Getting started complete". The visit rows themselves do not count.
|
||||
*/
|
||||
export function shouldAcknowledgeVisitSteps(checklist: Checklist): boolean {
|
||||
return checklist.items
|
||||
.filter((item) => !item.locked && !VISIT_STEP_KEYS.includes(item.key))
|
||||
.every((item) => item.done || item.skipped);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,14 +47,27 @@ const upToManual: OnboardingCounters = {
|
|||
transactions: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* The checklist of `counters`. The visit steps are acknowledged and initialized
|
||||
* unless told otherwise (rule of the templates, #425): the fixtures below are
|
||||
* about the six data steps.
|
||||
*/
|
||||
const checklistOf = (
|
||||
counters: OnboardingCounters,
|
||||
options: { importFolderSet?: boolean; budgetAllowed?: boolean; skipped?: ChecklistItemKey[] } = {},
|
||||
options: {
|
||||
importFolderSet?: boolean;
|
||||
budgetAllowed?: boolean;
|
||||
skipped?: ChecklistItemKey[];
|
||||
acknowledged?: ChecklistItemKey[];
|
||||
visitStepsInitialized?: boolean;
|
||||
} = {},
|
||||
): Checklist =>
|
||||
computeChecklist(counters, {
|
||||
importFolderSet: options.importFolderSet ?? false,
|
||||
budgetAllowed: options.budgetAllowed ?? true,
|
||||
skipped: options.skipped,
|
||||
acknowledged: options.acknowledged ?? ["categories", "reports"],
|
||||
visitStepsInitialized: options.visitStepsInitialized ?? true,
|
||||
});
|
||||
|
||||
/** A row of the given step, as `computeChecklist` builds it. */
|
||||
|
|
@ -112,6 +125,22 @@ describe("nextStep (#396)", () => {
|
|||
expect(nextStep(checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true })))
|
||||
.toMatchObject({ key: "manualCategory", to: "/transactions" });
|
||||
});
|
||||
|
||||
it("leads to the visit steps once initialized and not acknowledged (#425)", () => {
|
||||
const imported = { ...zero, sources: 1, imports: 1 };
|
||||
expect(nextStep(checklistOf(imported, { importFolderSet: true, acknowledged: [] })))
|
||||
.toMatchObject({ key: "categories", to: "/categories" });
|
||||
const allButReports = { ...upToManual, budgetEntries: 1 };
|
||||
expect(
|
||||
nextStep(checklistOf(allButReports, { importFolderSet: true, acknowledged: ["categories"] })),
|
||||
).toMatchObject({ key: "reports", to: "/reports" });
|
||||
// Before the initialization, they are passed over.
|
||||
expect(
|
||||
nextStep(
|
||||
checklistOf(imported, { importFolderSet: true, acknowledged: [], visitStepsInitialized: false }),
|
||||
)?.key,
|
||||
).toBe("manualCategory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepsOfPage (#396)", () => {
|
||||
|
|
@ -286,7 +315,7 @@ describe("bannerMode (#396)", () => {
|
|||
});
|
||||
|
||||
it("waits for the Budget entitlement before announcing the end", () => {
|
||||
// Until the license is known, Budget counts as locked: 5/5 could be 5/6.
|
||||
// Until the license is known, Budget counts as locked: 7/7 could be 7/8.
|
||||
const failClosed = checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false });
|
||||
expect(failClosed.complete).toBe(true);
|
||||
expect(mode({ checklist: failClosed, budgetReady: false })).toBe("none");
|
||||
|
|
@ -296,6 +325,17 @@ describe("bannerMode (#396)", () => {
|
|||
it("shows the banner before the Budget entitlement is known when steps remain", () => {
|
||||
expect(mode({ budgetReady: false })).toBe("banner");
|
||||
});
|
||||
|
||||
it("before the visit steps' initialization, a complete path is complete without them (#425)", () => {
|
||||
// The provider passes `budgetReady && visitStepsInitialized`: until then, no end.
|
||||
const beforeInit = checklistOf(
|
||||
{ ...upToManual, budgetEntries: 1 },
|
||||
{ importFolderSet: true, acknowledged: [], visitStepsInitialized: false },
|
||||
);
|
||||
expect(beforeInit.complete).toBe(true);
|
||||
expect(nextStep(beforeInit)).toBeNull();
|
||||
expect(mode({ checklist: beforeInit, budgetReady: false })).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readOnboardingNavState (#396)", () => {
|
||||
|
|
@ -391,12 +431,12 @@ describe("cardStep (#398)", () => {
|
|||
};
|
||||
|
||||
it("on /import, shows the first of the page's steps not done yet, ranked among the unlocked rows", () => {
|
||||
expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 6 });
|
||||
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 6 });
|
||||
expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 8 });
|
||||
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 8 });
|
||||
expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({
|
||||
key: "import",
|
||||
n: 4,
|
||||
total: 6,
|
||||
total: 8,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -404,7 +444,7 @@ describe("cardStep (#398)", () => {
|
|||
expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({
|
||||
key: "import",
|
||||
n: 4,
|
||||
total: 6,
|
||||
total: 8,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -418,17 +458,23 @@ describe("cardStep (#398)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("counts the unlocked rows only: five in the Free edition", () => {
|
||||
it("counts the unlocked rows only: seven in the Free edition", () => {
|
||||
expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({
|
||||
key: "importFolder",
|
||||
n: 2,
|
||||
total: 5,
|
||||
total: 7,
|
||||
});
|
||||
expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({
|
||||
key: "manualCategory",
|
||||
n: 5,
|
||||
total: 6,
|
||||
n: 6,
|
||||
total: 8,
|
||||
});
|
||||
expect(at(checklistOf(zero), "/budget")).toEqual({ key: "budget", n: 7, total: 8 });
|
||||
});
|
||||
|
||||
it("ranks the steps without the visit steps until they are initialized (#425)", () => {
|
||||
const beforeInit = checklistOf(upToManual, { importFolderSet: true, visitStepsInitialized: false });
|
||||
expect(at(beforeInit, "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6 });
|
||||
});
|
||||
|
||||
it("is null on a page that guides no unlocked step", () => {
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export interface BannerModeInput {
|
|||
pathname: string;
|
||||
/** The dashboard of a profile without any transaction (D11). */
|
||||
isEmptyDashboard: boolean;
|
||||
/** False until the Budget entitlement is known. */
|
||||
/** False until the Budget entitlement is known (and, from the provider, the visit steps initialized, #425). */
|
||||
budgetReady: boolean;
|
||||
/** The latched card page (`latchCardPage`). */
|
||||
cardPage: string | null;
|
||||
|
|
@ -102,7 +102,9 @@ export interface BannerModeInput {
|
|||
* then says so itself);
|
||||
* - `complete` when every unlocked step is done or skipped (D4), and only once
|
||||
* Budget's entitlement is known: until then the checklist is computed with
|
||||
* Budget locked, and "complete" could be a sixth step away;
|
||||
* Budget locked, and "complete" could be one step away; the provider passes
|
||||
* `budgetReady && visitStepsInitialized` as `budgetReady` (#425), the visit
|
||||
* steps being locked the same way until their initialization;
|
||||
* - `banner` everywhere else. Its counter waits for `budgetReady` too.
|
||||
*
|
||||
* A failed re-read after a first success keeps status `ready` (reducer), so
|
||||
|
|
@ -209,7 +211,8 @@ export type StepCardNext =
|
|||
* - `step`: the next step, which lives on another page;
|
||||
* - `end`: no step left (end mode), once the Budget entitlement is known:
|
||||
* until then the checklist counts Budget as locked, and the end could be a
|
||||
* sixth step away (the rule of `bannerMode`);
|
||||
* step away (the rule of `bannerMode`); the caller passes
|
||||
* `budgetReady && visitStepsInitialized` (#425);
|
||||
* - `wait`: nothing yet, while the entitlement is unknown at the end, or
|
||||
* while the next step is still one of this page's own: the re-read that
|
||||
* follows the write that completed them (ADR 0022) has not landed, and a
|
||||
|
|
|
|||
Loading…
Reference in a new issue