feat(onboarding): shared getting-started state, live re-read and data-change channel (ADR 0022) #402
37 changed files with 3478 additions and 225 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- La liste de démarrage ne s'affiche plus dans un profil qui avait déjà des transactions avant cette version : à la première ouverture d'un tel profil, la liste est masquée, comme après un clic sur « Masquer », et « Afficher la liste de démarrage », dans Paramètres → Utilisateurs, la fait revenir. Un profil sans transaction la garde (#396).
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|
||||||
- Classer une transaction à la main sur la page Transactions coche désormais l'étape « Classer une transaction à la main » de la liste de démarrage. L'application enregistrait cette marque sous une forme que la liste ne savait pas compter. Les transactions classées à la main avant cette version sont comptées dès l'ouverture du profil, et une sauvegarde exportée entre-temps les restaure correctement (#390).
|
- Classer une transaction à la main sur la page Transactions coche désormais l'étape « Classer une transaction à la main » de la liste de démarrage. L'application enregistrait cette marque sous une forme que la liste ne savait pas compter. Les transactions classées à la main avant cette version sont comptées dès l'ouverture du profil, et une sauvegarde exportée entre-temps les restaure correctement (#390).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The getting-started checklist no longer shows in a profile that already had transactions before this version: the first time such a profile is opened, the checklist is hidden, as if you had clicked "Hide", and "Show the getting-started checklist" in Settings → Users brings it back. A profile without transactions keeps it (#396).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Categorizing a transaction by hand on the Transactions page now ticks the "Categorize a transaction by hand" step of the getting-started checklist. The app stored that mark in a form the checklist could not count. Transactions you categorized by hand before this version are counted as soon as the profile is opened, and a backup exported in the meantime restores them correctly (#390).
|
- Categorizing a transaction by hand on the Transactions page now ticks the "Categorize a transaction by hand" step of the getting-started checklist. The app stored that mark in a form the checklist could not count. Transactions you categorized by hand before this version are counted as soon as the profile is opened, and a backup exported in the meantime restores them correctly (#390).
|
||||||
|
|
|
||||||
90
docs/adr/0022-canal-evenements-donnees.md
Normal file
90
docs/adr/0022-canal-evenements-donnees.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# ADR 0022 — Canal d'événements de données : les écritures signalent, le parcours de démarrage relit
|
||||||
|
|
||||||
|
- Status: **Accepted**
|
||||||
|
- Date: 2026-10-08
|
||||||
|
- Issues: #396 (milestone `planned-2026-10-06-onboarding-parcours-guide`, phase 2 de l'onboarding, #391)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
La phase 2 de l'onboarding fait suivre l'utilisateur par le parcours de démarrage : un bandeau sur chaque page et, sur la page de l'étape en cours, une carte qui voit l'étape se cocher pendant que la page est ouverte. Il faut donc que l'état du parcours soit partagé par toute l'application et relu après chaque écriture qui le concerne.
|
||||||
|
|
||||||
|
Rien ne le permettait. Les pages lisent leurs données à leur montage, et aucun mécanisme ne relie une écriture à un écran qui en dépend. La liste de démarrage de la phase 1 (#351) vivait dans `DashboardPage`, lue une fois au montage : choisir un dossier d'import, importer un fichier ou classer une transaction ne la mettait à jour qu'au retour sur le tableau de bord.
|
||||||
|
|
||||||
|
Le parcours se calcule depuis la base : cinq compteurs lus par `COUNTERS_SQL` (`onboardingService.ts`), sur `import_sources`, `imported_files`, `transactions` et `budget_entries`, plus la préférence `import_folder`. Ces données s'écrivent depuis plusieurs pages et plusieurs services, et une restauration de sauvegarde depuis Paramètres → Données les remplace toutes.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Un canal d'événements de données, `src/services/dataEvents.ts` : les écritures qui peuvent changer un compteur appellent `notifyDataChanged()` une fois réussies, et le fournisseur du parcours, abonné par `subscribeDataChanged`, relit son état.** Le module n'importe pas React ; il suit le modèle de `logService.subscribe`.
|
||||||
|
|
||||||
|
### 1. Portée
|
||||||
|
|
||||||
|
Le message dit seulement : « des données que lisent des compteurs ont peut-être changé ». Il ne porte rien, ni table, ni identifiant, ni valeur : l'abonné relit ce dont il a besoin, en une requête. Le nom est côté données, parce que la couche de données ne sait pas qui écoute : aucun service n'importe de hook ni de contexte.
|
||||||
|
|
||||||
|
`notifyDataChanged` est synchrone et ne lève jamais. Un abonné qui lève est écrit au Journal (`Data events: a subscriber failed`) et les autres sont appelés quand même : une écriture réussie n'est jamais présentée comme un échec à cause d'un abonné.
|
||||||
|
|
||||||
|
### 2. Les écritures qui notifient
|
||||||
|
|
||||||
|
La liste est construite depuis les tables que lit `COUNTERS_SQL`, plus le dossier d'import. Chaque notification part **après une écriture réussie** ; une écriture qui lève ne notifie pas. Une fonction qui écrit en plusieurs requêtes notifie une fois, après la dernière.
|
||||||
|
|
||||||
|
| Service | Écritures | Moment |
|
||||||
|
|---|---|---|
|
||||||
|
| `userPreferenceService.ts` | `setImportFolder` | après l'écriture de la préférence (`setPreference` seul ne notifie pas) |
|
||||||
|
| `importSourceService.ts` | `createSource`, `deleteSource` | après l'écriture ; `createSource` notifie avant la relecture qui suit un conflit de nom, qui ne fait que lire |
|
||||||
|
| `importedFileService.ts` | `createImportedFile` (insertion comme mise à jour), `updateFileStatus`, `deleteImportWithTransactions`, `deleteAllImportsWithTransactions` | après l'écriture ; pour les suppressions, après la dernière (source orpheline comprise). Un refus du contrôle préalable (transactions liées au Bilan) ne notifie pas |
|
||||||
|
| `transactionService.ts` | `insertBatch`, `updateTransactionCategory` | `insertBatch` une fois, après la dernière insertion |
|
||||||
|
| `categorizationService.ts` | `applyKeywordWithReassignment` | après le `COMMIT`, une fois le verrou de `db.ts` relâché ; un `ROLLBACK` relance l'erreur et ne notifie pas |
|
||||||
|
| `budgetService.ts` | `upsertBudgetEntry`, `deleteBudgetEntry`, `upsertBudgetEntriesForYear`, `applyTemplate` | après l'écriture ; `upsertBudgetEntriesForYear` après les douze mois, `applyTemplate` une fois après la dernière entrée du modèle |
|
||||||
|
| `dataExportService.ts` | `runRestore` | après le `COMMIT`, une fois le verrou relâché : un seul point pour les trois modes de restauration |
|
||||||
|
| `useImportWizard.ts` | fin d'`executeImport` | une notification de plus, en succès comme en échec (`finally`), pour que la dernière relecture voie l'état final de l'import |
|
||||||
|
|
||||||
|
Cette liste ne doit pas rétrécir. Sans `insertBatch` ni `updateFileStatus`, la dernière relecture d'un import partait avant l'insertion des transactions, et le parcours restait sur un état intermédiaire.
|
||||||
|
|
||||||
|
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 ;
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
**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é.
|
||||||
|
|
||||||
|
### 3. Une attente de 150 ms côté abonné
|
||||||
|
|
||||||
|
L'émetteur n'attend rien. L'abonné attend 150 ms sans nouvel événement avant de relire (`followDataChanges`, `useOnboardingChecklist.ts`) : chaque événement relance l'attente, et une rafale, comme les écritures d'un import (source, fichiers, insertions, fin), coûte une seule relecture. Le démontage de l'abonné désabonne et annule une relecture en attente.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 5. Abonnés admis
|
||||||
|
|
||||||
|
Un abonné :
|
||||||
|
|
||||||
|
- relit, et n'écrit rien en réponse : une écriture déclenchée par une notification ouvrirait une boucle ;
|
||||||
|
- regroupe les rafales par une attente, et ignore les réponses périmées ;
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- Le parcours suit l'utilisateur en direct, sur toutes les pages, sans retour au tableau de bord.
|
||||||
|
- Une seule liste d'écritures, au même endroit que le code qui écrit, et gardée par un test par service.
|
||||||
|
- Les services restent ignorants de l'interface : ils n'importent qu'un module sans React.
|
||||||
|
|
||||||
|
### Négatives et points de vigilance
|
||||||
|
|
||||||
|
- **La liste est manuelle.** Une écriture oubliée ne coche pas l'étape en direct ; la relecture au changement de page la rattrape.
|
||||||
|
- **Une écriture en plusieurs requêtes sans transaction qui échoue à mi-chemin ne notifie pas**, alors que des lignes ont pu changer (par exemple `insertBatch` après quelques insertions). La notification finale d'`executeImport`, ou la relecture suivante, rattrape.
|
||||||
|
- **La notification est synchrone.** Un abonné lent ralentirait l'écriture qui notifie : la règle des abonnés admis l'interdit.
|
||||||
|
- **Le canal est global à la fenêtre.** Un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`) ; le fournisseur du profil précédent se désabonne alors et annule sa relecture en attente.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
- **Relire la base à intervalle fixe** : des requêtes sans raison sur le verrou unique de `db.ts`, et une étape qui se coche avec un retard.
|
||||||
|
- **Un store global de données** : contraire à l'[ADR 0002](0002-useReducer-vs-redux.md), et un store ne sait pas mieux qu'un canal quand la base change.
|
||||||
|
- **Notifier depuis `db.ts` à chaque `execute`** : un import écrit une requête par transaction, et le canal ne saurait pas distinguer une table d'une autre ; il deviendrait bavard et sans portée.
|
||||||
|
- **Des événements typés (table, identifiant)** : plus de couplage, et aucun abonné n'en a besoin, puisque les compteurs se relisent en une requête.
|
||||||
|
- **Faire appeler `refresh()` par les hooks des pages** : chaque page dépendrait du parcours, et une écriture faite ailleurs, comme une restauration depuis Paramètres → Données, serait oubliée.
|
||||||
|
|
@ -39,10 +39,10 @@ simpl-resultat/
|
||||||
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
|
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
|
||||||
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState)
|
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState)
|
||||||
│ │ └── transactions/ # 5 composants
|
│ │ └── transactions/ # 5 composants
|
||||||
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
|
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
|
||||||
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
|
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
|
||||||
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
||||||
│ ├── services/ # 25 services métier (19 détaillés dans la section « Services TypeScript »)
|
│ ├── services/ # 26 services métier (20 détaillés dans la section « Services TypeScript »)
|
||||||
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
||||||
│ ├── utils/ # 14 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1)
|
│ ├── utils/ # 14 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1)
|
||||||
│ ├── i18n/ # Config i18next + locales FR/EN
|
│ ├── i18n/ # Config i18next + locales FR/EN
|
||||||
|
|
@ -177,7 +177,7 @@ Les **profils secondaires** (`profile_<id>.db`) sont créés par `get_new_profil
|
||||||
|
|
||||||
Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`).
|
Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`).
|
||||||
|
|
||||||
## Services TypeScript (19)
|
## Services TypeScript (20)
|
||||||
|
|
||||||
| Service | Responsabilité |
|
| Service | Responsabilité |
|
||||||
|---------|---------------|
|
|---------|---------------|
|
||||||
|
|
@ -195,7 +195,8 @@ 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) |
|
| `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` |
|
| `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 |
|
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||||
| `onboardingService.ts` | Liste de démarrage (#351) : `getOnboardingCounters` 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 et la préférence `onboarding_checklist_hidden` ; `hideChecklist` / `showChecklist` posent et retirent la préférence. 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` 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 » |
|
||||||
|
| `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) |
|
| `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 |
|
| `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 |
|
||||||
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
|
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
|
||||||
|
|
@ -226,7 +227,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `useAdjustments` | Ajustements |
|
| `useAdjustments` | Ajustements |
|
||||||
| `useBudget` | Budget |
|
| `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 |
|
| `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` | Liste de démarrage (#351) : reducer exporté (`onboardingChecklistReducer`, états `loading` / `ready` / `error`), charge le snapshot d'`onboardingService` au montage et le passe à `computeChecklist` avec `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `hide()` / `show()` / `refresh()`. Un échec de lecture garde les compteurs précédents et laisse `hidden` intact — une lecture ratée ne ressuscite jamais une liste masquée ; le Dashboard rend alors la page sans liste et journalise |
|
| `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()` |
|
||||||
| `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) |
|
| `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 |
|
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||||
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
||||||
|
|
@ -606,3 +607,4 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
|
||||||
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |
|
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |
|
||||||
| [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted |
|
| [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted |
|
||||||
| [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted |
|
| [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted |
|
||||||
|
| [0022](adr/0022-canal-evenements-donnees.md) | Canal d'événements de données : les écritures signalent, le parcours de démarrage relit | 2026-10-08 | Accepted |
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
|
import { OnboardingProvider } from "../../contexts/OnboardingContext";
|
||||||
|
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
{/* The getting-started path, shared by every page (#396). */}
|
||||||
|
<OnboardingProvider>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</OnboardingProvider>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
71
src/contexts/OnboardingContext.render.test.tsx
Normal file
71
src/contexts/OnboardingContext.render.test.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// OnboardingProvider wiring (#396). No jsdom: react-dom/server renders in
|
||||||
|
// vitest's node environment. Effects do not run under renderToStaticMarkup, so
|
||||||
|
// what is pinned here is the mounting, not the loads (the loader, the reducer
|
||||||
|
// and the derivation are tested in useOnboardingChecklist.test.ts).
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
|
vi.mock("./LicenseContext", () => ({
|
||||||
|
useLicenseContext: () => ({ status: "ready", edition: "base", features: [] }),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/layout/Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
|
||||||
|
|
||||||
|
import AppShell from "../components/layout/AppShell";
|
||||||
|
import { OnboardingProvider, useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const onboarding = useOnboarding();
|
||||||
|
return (
|
||||||
|
<output>
|
||||||
|
{[
|
||||||
|
onboarding.status,
|
||||||
|
onboarding.mode,
|
||||||
|
String(onboarding.cardPage),
|
||||||
|
String(onboarding.nextStep),
|
||||||
|
String(onboarding.checklist),
|
||||||
|
typeof onboarding.skip,
|
||||||
|
typeof onboarding.refresh,
|
||||||
|
].join("|")}
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OnboardingProvider (#396)", () => {
|
||||||
|
it("useOnboarding throws outside the provider", () => {
|
||||||
|
expect(() =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Probe />
|
||||||
|
</MemoryRouter>,
|
||||||
|
),
|
||||||
|
).toThrow("useOnboarding must be used within OnboardingProvider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares the path, still loading on the first render", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<MemoryRouter initialEntries={["/import"]}>
|
||||||
|
<OnboardingProvider>
|
||||||
|
<Probe />
|
||||||
|
</OnboardingProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("loading|none|null|null|null|function|function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AppShell mounts the provider around the routed page", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<MemoryRouter initialEntries={["/transactions"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="/transactions" element={<Probe />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("sidebar");
|
||||||
|
expect(html).toContain("<main");
|
||||||
|
expect(html).toContain("loading|none|");
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/contexts/OnboardingContext.tsx
Normal file
26
src/contexts/OnboardingContext.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import { useOnboardingChecklist, type OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The getting-started path, shared by every page (#396).
|
||||||
|
*
|
||||||
|
* A thin provider: the state lives in the reducer of `useOnboardingChecklist`
|
||||||
|
* (one `useReducer` hook per domain), which this component calls once. It is
|
||||||
|
* mounted in `AppShell`, around the routed page, so it survives navigation and
|
||||||
|
* re-reads on each route change; a profile switch remounts AppShell
|
||||||
|
* (`BrowserRouter key={refreshKey}`) and with it a fresh provider, whose
|
||||||
|
* cleanups drop the previous profile's subscription and timer.
|
||||||
|
*/
|
||||||
|
const OnboardingContext = createContext<OnboardingValue | null>(null);
|
||||||
|
|
||||||
|
export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useOnboardingChecklist();
|
||||||
|
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared getting-started path. Throws outside `OnboardingProvider`. */
|
||||||
|
export function useOnboarding(): OnboardingValue {
|
||||||
|
const ctx = useContext(OnboardingContext);
|
||||||
|
if (!ctx) throw new Error("useOnboarding must be used within OnboardingProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
144
src/hooks/useImportWizard.executeImport.test.ts
Normal file
144
src/hooks/useImportWizard.executeImport.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
// useImportWizard — `executeImport` announces its end on the data channel (#396).
|
||||||
|
//
|
||||||
|
// No renderer in this project, and the reducer alone cannot reach the import:
|
||||||
|
// `executeImport` is a callback of the hook. React's three hooks used by
|
||||||
|
// `useImportWizard` are stubbed so the hook runs as a plain function on a
|
||||||
|
// prepared state, and the services it calls are mocked. What is pinned here is
|
||||||
|
// the ADR 0022 contract: one last notification once the import is over,
|
||||||
|
// succeeded or not, after its inserts and file statuses.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const harness = vi.hoisted(() => ({
|
||||||
|
state: null as unknown,
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useReducer: () => [harness.state, harness.dispatch],
|
||||||
|
useCallback: (fn: unknown) => fn,
|
||||||
|
useEffect: () => undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => "hash-of-file") }));
|
||||||
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
|
vi.mock("../services/dataEvents", () => ({
|
||||||
|
notifyDataChanged: vi.fn(),
|
||||||
|
subscribeDataChanged: vi.fn(() => () => undefined),
|
||||||
|
}));
|
||||||
|
vi.mock("../services/userPreferenceService", () => ({
|
||||||
|
getImportFolder: vi.fn(async () => null),
|
||||||
|
setImportFolder: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../services/importSourceService", () => ({
|
||||||
|
getAllSources: vi.fn(async () => []),
|
||||||
|
getSourceByName: vi.fn(async () => null),
|
||||||
|
createSource: vi.fn(async () => 7),
|
||||||
|
updateSource: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
vi.mock("../services/importedFileService", () => ({
|
||||||
|
existsByHash: vi.fn(async () => null),
|
||||||
|
createImportedFile: vi.fn(async () => 11),
|
||||||
|
updateFileStatus: vi.fn(async () => undefined),
|
||||||
|
getFilesBySourceId: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
vi.mock("../services/transactionService", () => ({
|
||||||
|
insertBatch: vi.fn(async (rows: unknown[]) => rows.length),
|
||||||
|
findDuplicates: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
vi.mock("../services/categorizationService", () => ({
|
||||||
|
categorizeBatch: vi.fn(async (descriptions: string[]) =>
|
||||||
|
descriptions.map(() => ({ category_id: null, supplier_id: null })),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../services/importConfigTemplateService", () => ({
|
||||||
|
getAllTemplates: vi.fn(async () => []),
|
||||||
|
createTemplate: vi.fn(),
|
||||||
|
updateTemplate: vi.fn(),
|
||||||
|
deleteTemplate: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useImportWizard, initialWizardState, type WizardState } from "./useImportWizard";
|
||||||
|
import { notifyDataChanged } from "../services/dataEvents";
|
||||||
|
import { createSource } from "../services/importSourceService";
|
||||||
|
import { createImportedFile, updateFileStatus } from "../services/importedFileService";
|
||||||
|
import { insertBatch } from "../services/transactionService";
|
||||||
|
import type { ParsedRow } from "../shared/types";
|
||||||
|
|
||||||
|
const rows: ParsedRow[] = [1, 2].map((i) => ({
|
||||||
|
rowIndex: i,
|
||||||
|
raw: [`2026-09-0${i}`, `Achat ${i}`, `-${i}`],
|
||||||
|
parsed: { date: `2026-09-0${i}`, description: `Achat ${i}`, amount: -i },
|
||||||
|
sourceFilename: "releve.csv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** The wizard on its "confirm" step, ready to import two rows of one file. */
|
||||||
|
const readyToImport = (): WizardState => ({
|
||||||
|
...initialWizardState,
|
||||||
|
step: "confirm",
|
||||||
|
sourceConfig: { ...initialWizardState.sourceConfig, name: "Desjardins" },
|
||||||
|
selectedFiles: [
|
||||||
|
{ filename: "releve.csv", file_path: "/releves/Desjardins/releve.csv", size_bytes: 10, modified_at: "" },
|
||||||
|
],
|
||||||
|
parsedPreview: rows,
|
||||||
|
previewHeaders: ["date", "description", "amount"],
|
||||||
|
duplicateResult: { fileAlreadyImported: false, duplicateRows: [], newRows: rows },
|
||||||
|
excludedDuplicateIndices: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The order in which a mock was first called, to compare with the notification. */
|
||||||
|
const order = (mock: unknown): number => vi.mocked(mock as () => void).mock.invocationCallOrder[0];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(notifyDataChanged).mockClear();
|
||||||
|
vi.mocked(createSource).mockClear();
|
||||||
|
vi.mocked(createImportedFile).mockClear();
|
||||||
|
vi.mocked(updateFileStatus).mockClear();
|
||||||
|
vi.mocked(insertBatch).mockClear();
|
||||||
|
harness.dispatch.mockClear();
|
||||||
|
harness.state = readyToImport();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executeImport — final data-change notification (#396)", () => {
|
||||||
|
it("notifies once at the end of a successful import, after the inserts", async () => {
|
||||||
|
await useImportWizard().executeImport();
|
||||||
|
|
||||||
|
expect(insertBatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(notifyDataChanged).toHaveBeenCalledTimes(1);
|
||||||
|
expect(order(notifyDataChanged)).toBeGreaterThan(order(insertBatch));
|
||||||
|
expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_STEP", payload: "report" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies once at the end when the inserts fail, after the file statuses are settled", async () => {
|
||||||
|
vi.mocked(insertBatch).mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
|
||||||
|
await useImportWizard().executeImport();
|
||||||
|
|
||||||
|
expect(updateFileStatus).toHaveBeenCalledWith(11, "error", 0, "Error: database is locked");
|
||||||
|
expect(notifyDataChanged).toHaveBeenCalledTimes(1);
|
||||||
|
expect(order(notifyDataChanged)).toBeGreaterThan(order(updateFileStatus));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies once at the end when the import fails before any insert", async () => {
|
||||||
|
vi.mocked(createSource).mockRejectedValueOnce(new Error("CHECK constraint failed"));
|
||||||
|
|
||||||
|
await useImportWizard().executeImport();
|
||||||
|
|
||||||
|
expect(insertBatch).not.toHaveBeenCalled();
|
||||||
|
expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_STEP", payload: "confirm" });
|
||||||
|
expect(notifyDataChanged).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when there is nothing to import (no duplicate check yet)", async () => {
|
||||||
|
harness.state = { ...readyToImport(), duplicateResult: null };
|
||||||
|
|
||||||
|
await useImportWizard().executeImport();
|
||||||
|
|
||||||
|
expect(createSource).not.toHaveBeenCalled();
|
||||||
|
expect(notifyDataChanged).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
findDuplicates,
|
findDuplicates,
|
||||||
} from "../services/transactionService";
|
} from "../services/transactionService";
|
||||||
import { categorizeBatch } from "../services/categorizationService";
|
import { categorizeBatch } from "../services/categorizationService";
|
||||||
|
import { notifyDataChanged } from "../services/dataEvents";
|
||||||
import {
|
import {
|
||||||
getAllTemplates,
|
getAllTemplates,
|
||||||
createTemplate,
|
createTemplate,
|
||||||
|
|
@ -1156,6 +1157,11 @@ export function useImportWizard() {
|
||||||
payload: e instanceof Error ? e.message : String(e),
|
payload: e instanceof Error ? e.message : String(e),
|
||||||
});
|
});
|
||||||
dispatch({ type: "SET_STEP", payload: "confirm" });
|
dispatch({ type: "SET_STEP", payload: "confirm" });
|
||||||
|
} finally {
|
||||||
|
// One more announcement once the import is over, succeeded or not, so
|
||||||
|
// the last re-read of the getting-started path sees its final state:
|
||||||
|
// inserts done, file statuses settled (ADR 0022).
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
state.duplicateResult,
|
state.duplicateResult,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
// useOnboardingChecklist — reducer unit tests (#351).
|
// useOnboardingChecklist — reducer, loader and data-channel follower (#351, #396).
|
||||||
//
|
//
|
||||||
// No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts):
|
// No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts):
|
||||||
// the exported reducer is driven directly.
|
// the exported reducer, loader and follower are driven directly.
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
// The hook module imports the onboarding service (→ db → tauri plugins) and
|
// The hook module imports the onboarding service (→ db → tauri plugins) and
|
||||||
// useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both
|
// useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both
|
||||||
|
|
@ -20,15 +20,24 @@ vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
|
||||||
import {
|
import {
|
||||||
onboardingChecklistReducer,
|
onboardingChecklistReducer,
|
||||||
initialOnboardingChecklistState,
|
initialOnboardingChecklistState,
|
||||||
|
createOnboardingLoader,
|
||||||
|
deriveOnboardingView,
|
||||||
|
followDataChanges,
|
||||||
|
DATA_CHANGE_DELAY_MS,
|
||||||
type OnboardingChecklistAction,
|
type OnboardingChecklistAction,
|
||||||
type OnboardingChecklistState,
|
type OnboardingChecklistState,
|
||||||
|
type OnboardingLoaderDeps,
|
||||||
} from "./useOnboardingChecklist";
|
} from "./useOnboardingChecklist";
|
||||||
|
import { notifyDataChanged } from "../services/dataEvents";
|
||||||
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||||
import type { OnboardingSnapshot } from "../services/onboardingService";
|
import type { OnboardingSnapshot } from "../services/onboardingService";
|
||||||
|
import type { OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
const snapshot: OnboardingSnapshot = {
|
const snapshot: OnboardingSnapshot = {
|
||||||
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
|
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
|
skipped: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const reduce = (
|
const reduce = (
|
||||||
|
|
@ -36,87 +45,688 @@ const reduce = (
|
||||||
from: OnboardingChecklistState = initialOnboardingChecklistState,
|
from: OnboardingChecklistState = initialOnboardingChecklistState,
|
||||||
): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from);
|
): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from);
|
||||||
|
|
||||||
describe("onboardingChecklistReducer (#351)", () => {
|
/** A first successful load (seq 1). */
|
||||||
it("starts loading with no counters and not hidden", () => {
|
const ready = (snap: OnboardingSnapshot = snapshot): OnboardingChecklistState =>
|
||||||
|
reduce([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_DONE", seq: 1, snapshot: snap },
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => {
|
||||||
|
it("starts loading with no counters, nothing hidden or skipped, and no card", () => {
|
||||||
expect(initialOnboardingChecklistState).toEqual({
|
expect(initialOnboardingChecklistState).toEqual({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
counters: null,
|
counters: null,
|
||||||
importFolderSet: false,
|
importFolderSet: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
error: null,
|
skipped: [],
|
||||||
|
loadSeq: 0,
|
||||||
|
hiddenSetAt: 0,
|
||||||
|
skippedSetAt: 0,
|
||||||
|
cardPage: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("LOAD_DONE stores the snapshot and sets ready", () => {
|
it("LOAD_DONE stores the snapshot and sets ready", () => {
|
||||||
const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_DONE", snapshot }]);
|
const s = ready({ ...snapshot, skipped: ["budget"] });
|
||||||
expect(s.status).toBe("ready");
|
expect(s.status).toBe("ready");
|
||||||
expect(s.counters).toEqual(snapshot.counters);
|
expect(s.counters).toEqual(snapshot.counters);
|
||||||
expect(s.importFolderSet).toBe(true);
|
expect(s.importFolderSet).toBe(true);
|
||||||
expect(s.hidden).toBe(false);
|
expect(s.hidden).toBe(false);
|
||||||
expect(s.error).toBeNull();
|
expect(s.skipped).toEqual(["budget"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("LOAD_DONE applies the persisted hidden flag", () => {
|
it("LOAD_DONE applies the persisted hidden flag", () => {
|
||||||
const s = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
|
expect(ready({ ...snapshot, hidden: true }).hidden).toBe(true);
|
||||||
expect(s.hidden).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("LOAD_ERROR sets status=error with the message and keeps hidden", () => {
|
it("a stale LOAD_DONE (an older load answering after a newer one started) is ignored", () => {
|
||||||
const hiddenReady = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
|
const s = reduce([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: true } },
|
||||||
|
]);
|
||||||
|
expect(s.status).toBe("loading");
|
||||||
|
expect(s.counters).toBeNull();
|
||||||
|
expect(s.hidden).toBe(false);
|
||||||
|
|
||||||
|
const answered = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], s);
|
||||||
|
expect(answered.status).toBe("ready");
|
||||||
|
expect(answered.counters).toEqual(snapshot.counters);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale LOAD_DONE arriving after the newer one does not overwrite it", () => {
|
||||||
|
const newer = { ...snapshot, counters: { ...snapshot.counters, imports: 4 } };
|
||||||
|
const s = reduce([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: newer },
|
||||||
|
{ type: "LOAD_DONE", seq: 1, snapshot },
|
||||||
|
]);
|
||||||
|
expect(s.counters?.imports).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a refresh keeps ready and the counters while it runs", () => {
|
||||||
|
const s = reduce([{ type: "LOAD_START", seq: 2 }], ready());
|
||||||
|
expect(s.status).toBe("ready");
|
||||||
|
expect(s.counters).toEqual(snapshot.counters);
|
||||||
|
expect(s.importFolderSet).toBe(true);
|
||||||
|
expect(s.loadSeq).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed re-read after a first success keeps ready and the last state", () => {
|
||||||
|
const hiddenReady = ready({ ...snapshot, hidden: true, skipped: ["source"] });
|
||||||
const s = reduce(
|
const s = reduce(
|
||||||
[{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "database is locked" }],
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "LOAD_ERROR", seq: 2 },
|
||||||
|
],
|
||||||
hiddenReady,
|
hiddenReady,
|
||||||
);
|
);
|
||||||
expect(s.status).toBe("error");
|
expect(s.status).toBe("ready");
|
||||||
expect(s.error).toBe("database is locked");
|
|
||||||
expect(s.hidden).toBe(true);
|
|
||||||
// The last known counters survive a failed refresh.
|
|
||||||
expect(s.counters).toEqual(snapshot.counters);
|
expect(s.counters).toEqual(snapshot.counters);
|
||||||
|
expect(s.hidden).toBe(true);
|
||||||
|
expect(s.skipped).toEqual(["source"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("LOAD_ERROR on a fresh state keeps hidden=false", () => {
|
it("a first load that fails sets status=error and keeps hidden=false", () => {
|
||||||
const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]);
|
const s = reduce([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_ERROR", seq: 1 },
|
||||||
|
]);
|
||||||
expect(s.status).toBe("error");
|
expect(s.status).toBe("error");
|
||||||
expect(s.hidden).toBe(false);
|
expect(s.hidden).toBe(false);
|
||||||
expect(s.counters).toBeNull();
|
expect(s.counters).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refresh after an error goes back to loading, then ready", () => {
|
it("a stale LOAD_ERROR is ignored", () => {
|
||||||
const errored = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]);
|
const s = reduce([
|
||||||
const loading = reduce([{ type: "LOAD_START" }], errored);
|
{ type: "LOAD_START", seq: 1 },
|
||||||
expect(loading.status).toBe("loading");
|
{ type: "LOAD_START", seq: 2 },
|
||||||
expect(loading.error).toBeNull();
|
{ type: "LOAD_ERROR", seq: 1 },
|
||||||
const ready = reduce([{ type: "LOAD_DONE", snapshot }], loading);
|
]);
|
||||||
expect(ready.status).toBe("ready");
|
|
||||||
expect(ready.error).toBeNull();
|
|
||||||
expect(ready.counters).toEqual(snapshot.counters);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("HIDE sets hidden=true without touching the load state", () => {
|
|
||||||
const ready = reduce([{ type: "LOAD_DONE", snapshot }]);
|
|
||||||
const s = reduce([{ type: "HIDE" }], ready);
|
|
||||||
expect(s.hidden).toBe(true);
|
|
||||||
expect(s.status).toBe("ready");
|
|
||||||
expect(s.counters).toEqual(snapshot.counters);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SHOW sets hidden=false without touching the load state", () => {
|
|
||||||
const hidden = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
|
|
||||||
const s = reduce([{ type: "SHOW" }], hidden);
|
|
||||||
expect(s.hidden).toBe(false);
|
|
||||||
expect(s.status).toBe("ready");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("HIDE while loading does not change the status", () => {
|
|
||||||
const s = reduce([{ type: "LOAD_START" }, { type: "HIDE" }]);
|
|
||||||
expect(s.status).toBe("loading");
|
expect(s.status).toBe("loading");
|
||||||
expect(s.hidden).toBe(true);
|
});
|
||||||
|
|
||||||
|
it("a retry after a failed first load goes back to loading, then ready", () => {
|
||||||
|
const errored = reduce([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_ERROR", seq: 1 },
|
||||||
|
]);
|
||||||
|
const loading = reduce([{ type: "LOAD_START", seq: 2 }], errored);
|
||||||
|
expect(loading.status).toBe("loading");
|
||||||
|
const done = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], loading);
|
||||||
|
expect(done.status).toBe("ready");
|
||||||
|
expect(done.counters).toEqual(snapshot.counters);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never mutates the previous state", () => {
|
it("never mutates the previous state", () => {
|
||||||
const before = reduce([{ type: "LOAD_DONE", snapshot }]);
|
const before = ready();
|
||||||
const frozen = Object.freeze({ ...before });
|
const frozen = Object.freeze({ ...before });
|
||||||
expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow();
|
expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow();
|
||||||
|
expect(() => onboardingChecklistReducer(frozen, { type: "SKIP", key: "budget" })).not.toThrow();
|
||||||
expect(frozen.hidden).toBe(false);
|
expect(frozen.hidden).toBe(false);
|
||||||
|
expect(frozen.skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onboardingChecklistReducer — local actions win over an older snapshot (#384, #396)", () => {
|
||||||
|
it("HIDE during a load wins over that load's snapshot", () => {
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "HIDE" },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } },
|
||||||
|
],
|
||||||
|
ready(),
|
||||||
|
);
|
||||||
|
expect(s.hidden).toBe(true);
|
||||||
|
// The rest of the snapshot still lands.
|
||||||
|
expect(s.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SHOW during a load wins over that load's snapshot", () => {
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "SHOW" },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } },
|
||||||
|
],
|
||||||
|
ready({ ...snapshot, hidden: true }),
|
||||||
|
);
|
||||||
|
expect(s.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SKIP during a load wins over that load's snapshot", () => {
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "SKIP", key: "budget" },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: [] } },
|
||||||
|
],
|
||||||
|
ready(),
|
||||||
|
);
|
||||||
|
expect(s.skipped).toEqual(["budget"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RESUME during a load wins over that load's snapshot", () => {
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "RESUME", key: "budget" },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["budget"] } },
|
||||||
|
],
|
||||||
|
ready({ ...snapshot, skipped: ["budget"] }),
|
||||||
|
);
|
||||||
|
expect(s.skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HIDE during the very first load wins too", () => {
|
||||||
|
const s = reduce([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "HIDE" },
|
||||||
|
{ type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: false } },
|
||||||
|
]);
|
||||||
|
expect(s.status).toBe("ready");
|
||||||
|
expect(s.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a load started after the action brings the stored value back", () => {
|
||||||
|
// The user hid the path, then showed it again from Settings → Users:
|
||||||
|
// a later load reads the preference, which is the truth again.
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "HIDE" },
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } },
|
||||||
|
],
|
||||||
|
ready(),
|
||||||
|
);
|
||||||
|
expect(s.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HIDE does not shield the skipped steps, nor SKIP the hidden flag", () => {
|
||||||
|
const afterHide = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "HIDE" },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["source"] } },
|
||||||
|
],
|
||||||
|
ready(),
|
||||||
|
);
|
||||||
|
expect(afterHide.skipped).toEqual(["source"]);
|
||||||
|
|
||||||
|
const afterSkip = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "SKIP", key: "budget" },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } },
|
||||||
|
],
|
||||||
|
ready(),
|
||||||
|
);
|
||||||
|
expect(afterSkip.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HIDE and SHOW do not change the load state", () => {
|
||||||
|
const hidden = reduce([{ type: "HIDE" }], ready());
|
||||||
|
expect(hidden.hidden).toBe(true);
|
||||||
|
expect(hidden.status).toBe("ready");
|
||||||
|
expect(hidden.counters).toEqual(snapshot.counters);
|
||||||
|
const shown = reduce([{ type: "SHOW" }], hidden);
|
||||||
|
expect(shown.hidden).toBe(false);
|
||||||
|
expect(shown.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HIDE while the first load runs keeps status loading", () => {
|
||||||
|
const s = reduce([{ type: "LOAD_START", seq: 1 }, { type: "HIDE" }]);
|
||||||
|
expect(s.status).toBe("loading");
|
||||||
|
expect(s.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onboardingChecklistReducer — skipped steps and the card latch (#396)", () => {
|
||||||
|
it("SKIP keeps checklist order and never duplicates a step", () => {
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "SKIP", key: "budget" },
|
||||||
|
{ type: "SKIP", key: "source" },
|
||||||
|
{ type: "SKIP", key: "budget" },
|
||||||
|
],
|
||||||
|
ready(),
|
||||||
|
);
|
||||||
|
expect(s.skipped).toEqual(["source", "budget"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RESUME removes only its step", () => {
|
||||||
|
const s = reduce([{ type: "RESUME", key: "source" }], ready({ ...snapshot, skipped: ["source", "budget"] }));
|
||||||
|
expect(s.skipped).toEqual(["budget"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SKIP releases the card latch; RESUME, HIDE and a load do not touch it", () => {
|
||||||
|
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
|
||||||
|
expect(latched.cardPage).toBe("/import");
|
||||||
|
expect(reduce([{ type: "SKIP", key: "importFolder" }], latched).cardPage).toBeNull();
|
||||||
|
expect(reduce([{ type: "RESUME", key: "importFolder" }], latched).cardPage).toBe("/import");
|
||||||
|
expect(
|
||||||
|
reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot },
|
||||||
|
],
|
||||||
|
latched,
|
||||||
|
).cardPage,
|
||||||
|
).toBe("/import");
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
latched,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Loader
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A promise whose resolution the test controls. */
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loaderDeps(overrides: Partial<OnboardingLoaderDeps> = {}) {
|
||||||
|
const actions: OnboardingChecklistAction[] = [];
|
||||||
|
const deps: OnboardingLoaderDeps = {
|
||||||
|
initializeGuidedPath: vi.fn(async () => ({ hidden: false })),
|
||||||
|
getOnboardingSnapshot: vi.fn(async () => snapshot),
|
||||||
|
dispatch: (action) => {
|
||||||
|
actions.push(action);
|
||||||
|
},
|
||||||
|
logError: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { deps, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createOnboardingLoader (#396)", () => {
|
||||||
|
it("numbers its loads and dispatches LOAD_START then LOAD_DONE with the same number", async () => {
|
||||||
|
const { deps, actions } = loaderDeps();
|
||||||
|
const load = createOnboardingLoader(deps);
|
||||||
|
await load();
|
||||||
|
await load();
|
||||||
|
expect(actions).toEqual([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_DONE", seq: 1, snapshot },
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the first load reads the snapshot only once rule D9 is applied", async () => {
|
||||||
|
const init = deferred<{ hidden: boolean }>();
|
||||||
|
const { deps } = loaderDeps({ initializeGuidedPath: vi.fn(() => init.promise) });
|
||||||
|
const load = createOnboardingLoader(deps);
|
||||||
|
const pending = load();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(deps.getOnboardingSnapshot).not.toHaveBeenCalled();
|
||||||
|
init.resolve({ hidden: true });
|
||||||
|
await pending;
|
||||||
|
expect(deps.getOnboardingSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two concurrent first loads (StrictMode double mount) run D9 once and share it", async () => {
|
||||||
|
const init = deferred<{ hidden: boolean }>();
|
||||||
|
const { deps, actions } = loaderDeps({ initializeGuidedPath: vi.fn(() => init.promise) });
|
||||||
|
const load = createOnboardingLoader(deps);
|
||||||
|
const first = load();
|
||||||
|
const second = load();
|
||||||
|
init.resolve({ hidden: false });
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
expect(deps.initializeGuidedPath).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.getOnboardingSnapshot).toHaveBeenCalledTimes(2);
|
||||||
|
// Both answers are dispatched; the reducer keeps the one of load 2.
|
||||||
|
const final = actions.reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
|
||||||
|
expect(final.status).toBe("ready");
|
||||||
|
expect(final.loadSeq).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("later loads never run D9 again", async () => {
|
||||||
|
const { deps } = loaderDeps();
|
||||||
|
const load = createOnboardingLoader(deps);
|
||||||
|
await load();
|
||||||
|
await load();
|
||||||
|
await load();
|
||||||
|
expect(deps.initializeGuidedPath).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed D9 is logged and the load still reads the snapshot", async () => {
|
||||||
|
const { deps, actions } = loaderDeps({
|
||||||
|
initializeGuidedPath: vi.fn(async () => {
|
||||||
|
throw new Error("database is locked");
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await createOnboardingLoader(deps)();
|
||||||
|
expect(deps.logError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(deps.logError).mock.calls[0][1]).toBe("database is locked");
|
||||||
|
expect(actions[actions.length - 1]).toEqual({ type: "LOAD_DONE", seq: 1, snapshot });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed snapshot dispatches LOAD_ERROR with its number", async () => {
|
||||||
|
const { deps, actions } = loaderDeps({
|
||||||
|
getOnboardingSnapshot: vi.fn(async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await createOnboardingLoader(deps)();
|
||||||
|
expect(actions).toEqual([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_ERROR", seq: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a failure only when a failure streak starts", async () => {
|
||||||
|
const results: Array<"ok" | "fail"> = ["fail", "fail", "fail", "ok", "fail"];
|
||||||
|
const { deps } = loaderDeps({
|
||||||
|
getOnboardingSnapshot: vi.fn(async () => {
|
||||||
|
if (results.shift() === "fail") throw new Error("database is locked");
|
||||||
|
return snapshot;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const load = createOnboardingLoader(deps);
|
||||||
|
for (let i = 0; i < 5; i++) await load();
|
||||||
|
// Streak 1 (three failures) then streak 2 (one): two lines, not four.
|
||||||
|
expect(deps.logError).toHaveBeenCalledTimes(2);
|
||||||
|
expect(vi.mocked(deps.logError).mock.calls[0]).toEqual([
|
||||||
|
"Onboarding: failed to load the getting-started path",
|
||||||
|
"database is locked",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the failure of a stale load is not logged", async () => {
|
||||||
|
const first = deferred<OnboardingSnapshot>();
|
||||||
|
let calls = 0;
|
||||||
|
const { deps } = loaderDeps({
|
||||||
|
getOnboardingSnapshot: vi.fn(() => (++calls === 1 ? first.promise : Promise.resolve(snapshot))),
|
||||||
|
});
|
||||||
|
const load = createOnboardingLoader(deps);
|
||||||
|
const stale = load();
|
||||||
|
await load();
|
||||||
|
first.reject(new Error("late failure"));
|
||||||
|
await stale;
|
||||||
|
expect(deps.logError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider view: the path end to end, without rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deriveOnboardingView — the path end to end (#396)", () => {
|
||||||
|
// Every card page of the milestone, so the latch is exercised before the
|
||||||
|
// card issues add their pages to CARD_PAGES.
|
||||||
|
const cardPages = new Set(["/import", "/transactions", "/budget"]);
|
||||||
|
const empty: OnboardingCounters = {
|
||||||
|
sources: 0,
|
||||||
|
imports: 0,
|
||||||
|
manualCategorized: 0,
|
||||||
|
budgetEntries: 0,
|
||||||
|
transactions: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One render of the provider: derive, then store a latch that changed, as the hook does. */
|
||||||
|
function render(
|
||||||
|
state: OnboardingChecklistState,
|
||||||
|
pathname: string,
|
||||||
|
{ budgetAllowed = true, budgetReady = true } = {},
|
||||||
|
) {
|
||||||
|
const checklist = state.counters
|
||||||
|
? computeChecklist(state.counters, {
|
||||||
|
importFolderSet: state.importFolderSet,
|
||||||
|
budgetAllowed,
|
||||||
|
skipped: state.skipped,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages });
|
||||||
|
const settled =
|
||||||
|
view.cardPage === state.cardPage
|
||||||
|
? state
|
||||||
|
: onboardingChecklistReducer(state, { type: "SET_CARD_PAGE", cardPage: view.cardPage });
|
||||||
|
// The latch settles in one pass (React re-renders once after the update).
|
||||||
|
const again = deriveOnboardingView({ state: settled, checklist, pathname, budgetReady, cardPages });
|
||||||
|
expect(again.cardPage).toBe(settled.cardPage);
|
||||||
|
return { state: settled, view, checklist };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A load numbered after every previous one, answered with `snap`. */
|
||||||
|
function load(state: OnboardingChecklistState, snap: OnboardingSnapshot): OnboardingChecklistState {
|
||||||
|
const seq = state.loadSeq + 1;
|
||||||
|
return reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq },
|
||||||
|
{ type: "LOAD_DONE", seq, snapshot: snap },
|
||||||
|
],
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh: OnboardingSnapshot = { counters: empty, importFolderSet: false, hidden: false, skipped: [] };
|
||||||
|
|
||||||
|
it("shows nothing before the first load", () => {
|
||||||
|
const { view } = render(initialOnboardingChecklistState, "/import");
|
||||||
|
expect(view.mode).toBe("none");
|
||||||
|
expect(view.nextStep).toBeNull();
|
||||||
|
expect(view.cardPage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks a new profile from the folder to the budget, the card staying when its step gets done", () => {
|
||||||
|
let s = load(initialOnboardingChecklistState, fresh);
|
||||||
|
|
||||||
|
// Empty dashboard: the welcome card holds the list (D11).
|
||||||
|
let r = render(s, "/");
|
||||||
|
expect(r.view.mode).toBe("none");
|
||||||
|
expect(r.view.nextStep?.key).toBe("importFolder");
|
||||||
|
|
||||||
|
// On /import: the card of the folder step.
|
||||||
|
s = load(r.state, fresh);
|
||||||
|
r = render(s, "/import");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
expect(r.state.cardPage).toBe("/import");
|
||||||
|
|
||||||
|
// Folder chosen, page open: next step on the same page, same card.
|
||||||
|
s = load(r.state, { ...fresh, importFolderSet: true });
|
||||||
|
r = render(s, "/import");
|
||||||
|
expect(r.view.nextStep?.key).toBe("source");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
|
||||||
|
// Import done, page open: the next step lives on /transactions, the card
|
||||||
|
// stays on /import to say "Done." and offer it.
|
||||||
|
const imported = { ...empty, sources: 1, imports: 1, transactions: 20 };
|
||||||
|
s = load(r.state, { ...fresh, importFolderSet: true, counters: imported });
|
||||||
|
r = render(s, "/import");
|
||||||
|
expect(r.view.nextStep?.key).toBe("manualCategory");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
expect(r.state.cardPage).toBe("/import");
|
||||||
|
|
||||||
|
// Following "Next step": the latch moves to /transactions.
|
||||||
|
s = load(r.state, { ...fresh, importFolderSet: true, counters: imported });
|
||||||
|
r = render(s, "/transactions");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
expect(r.state.cardPage).toBe("/transactions");
|
||||||
|
|
||||||
|
// A category changed by hand, page open: the card stays.
|
||||||
|
const categorized = { ...imported, manualCategorized: 1 };
|
||||||
|
s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
|
||||||
|
r = render(s, "/transactions");
|
||||||
|
expect(r.view.nextStep?.key).toBe("budget");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
|
||||||
|
// Elsewhere: the banner.
|
||||||
|
s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
|
||||||
|
r = render(s, "/reports");
|
||||||
|
expect(r.view.mode).toBe("banner");
|
||||||
|
expect(r.state.cardPage).toBeNull();
|
||||||
|
|
||||||
|
// The budget page, then a first amount: the card stays, in its end mode.
|
||||||
|
s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized });
|
||||||
|
r = render(s, "/budget");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
s = load(r.state, {
|
||||||
|
...fresh,
|
||||||
|
importFolderSet: true,
|
||||||
|
counters: { ...categorized, budgetEntries: 1 },
|
||||||
|
});
|
||||||
|
r = render(s, "/budget");
|
||||||
|
expect(r.view.nextStep).toBeNull();
|
||||||
|
expect(r.checklist?.complete).toBe(true);
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
|
||||||
|
// Anywhere else: the end message (D4).
|
||||||
|
r = render(r.state, "/");
|
||||||
|
expect(r.view.mode).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Skip" towards a step of the same page keeps the card', () => {
|
||||||
|
let r = render(load(initialOnboardingChecklistState, fresh), "/import");
|
||||||
|
expect(r.state.cardPage).toBe("/import");
|
||||||
|
const skipped = reduce([{ type: "SKIP", key: "importFolder" }], r.state);
|
||||||
|
expect(skipped.cardPage).toBeNull();
|
||||||
|
r = render(skipped, "/import");
|
||||||
|
expect(r.view.nextStep?.key).toBe("source");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
expect(r.state.cardPage).toBe("/import");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Skip" towards a step of another page releases the card for the banner', () => {
|
||||||
|
const snap = {
|
||||||
|
...fresh,
|
||||||
|
importFolderSet: true,
|
||||||
|
counters: { ...empty, sources: 1, transactions: 20 },
|
||||||
|
};
|
||||||
|
let r = render(load(initialOnboardingChecklistState, snap), "/import");
|
||||||
|
expect(r.view.nextStep?.key).toBe("import");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
r = render(reduce([{ type: "SKIP", key: "import" }], r.state), "/import");
|
||||||
|
expect(r.view.nextStep?.key).toBe("manualCategory");
|
||||||
|
expect(r.view.mode).toBe("banner");
|
||||||
|
expect(r.state.cardPage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Skip" of the last step on its page ends the path with the end message', () => {
|
||||||
|
const snap = {
|
||||||
|
...fresh,
|
||||||
|
importFolderSet: true,
|
||||||
|
counters: { ...empty, sources: 1, imports: 1, manualCategorized: 1, transactions: 20 },
|
||||||
|
};
|
||||||
|
let r = render(load(initialOnboardingChecklistState, snap), "/budget");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
r = render(reduce([{ type: "SKIP", key: "budget" }], r.state), "/budget");
|
||||||
|
expect(r.view.mode).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hiding releases the card and shows nothing; showing again brings it back", () => {
|
||||||
|
let r = render(load(initialOnboardingChecklistState, fresh), "/import");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
r = render(reduce([{ type: "HIDE" }], r.state), "/import");
|
||||||
|
expect(r.view.mode).toBe("none");
|
||||||
|
expect(r.state.cardPage).toBeNull();
|
||||||
|
r = render(reduce([{ type: "SHOW" }], r.state), "/import");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a profile hidden by rule D9 shows nothing on any page", () => {
|
||||||
|
const s = load(initialOnboardingChecklistState, {
|
||||||
|
...fresh,
|
||||||
|
hidden: true,
|
||||||
|
counters: { ...empty, transactions: 40 },
|
||||||
|
});
|
||||||
|
for (const page of ["/", "/import", "/transactions", "/budget", "/reports"]) {
|
||||||
|
expect(render(s, page).view.mode).toBe("none");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Free edition: five steps, never the budget card, and the end at 5/5", () => {
|
||||||
|
const snap = {
|
||||||
|
...fresh,
|
||||||
|
importFolderSet: true,
|
||||||
|
counters: { ...empty, sources: 1, imports: 1, manualCategorized: 1, transactions: 20 },
|
||||||
|
};
|
||||||
|
const s = load(initialOnboardingChecklistState, snap);
|
||||||
|
const r = render(s, "/budget", { budgetAllowed: false });
|
||||||
|
expect(r.checklist?.total).toBe(5);
|
||||||
|
expect(r.view.nextStep).toBeNull();
|
||||||
|
expect(r.state.cardPage).toBeNull();
|
||||||
|
expect(r.view.mode).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed re-read keeps the card and the counters on screen", () => {
|
||||||
|
let r = render(load(initialOnboardingChecklistState, fresh), "/import");
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: r.state.loadSeq + 1 },
|
||||||
|
{ type: "LOAD_ERROR", seq: r.state.loadSeq + 1 },
|
||||||
|
],
|
||||||
|
r.state,
|
||||||
|
);
|
||||||
|
r = render(s, "/import");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
expect(r.checklist?.total).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data-channel follower
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("followDataChanges (#396, ADR 0022)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits 150 ms after an event before re-reading", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const stop = followDataChanges(run);
|
||||||
|
notifyDataChanged();
|
||||||
|
vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS - 1);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups a burst of events into one re-read, 150 ms after the last one", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const stop = followDataChanges(run);
|
||||||
|
notifyDataChanged();
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
notifyDataChanged();
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
notifyDataChanged();
|
||||||
|
vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS - 1);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the cleanup drops a pending re-read and unsubscribes", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const stop = followDataChanges(run);
|
||||||
|
notifyDataChanged();
|
||||||
|
stop();
|
||||||
|
vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS * 2);
|
||||||
|
notifyDataChanged();
|
||||||
|
vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS * 2);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,139 @@
|
||||||
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
getOnboardingCounters,
|
getOnboardingSnapshot,
|
||||||
hideChecklist,
|
hideChecklist,
|
||||||
|
initializeGuidedPath,
|
||||||
|
resumeStep,
|
||||||
showChecklist,
|
showChecklist,
|
||||||
|
skipStep,
|
||||||
type OnboardingSnapshot,
|
type OnboardingSnapshot,
|
||||||
} from "../services/onboardingService";
|
} from "../services/onboardingService";
|
||||||
|
import { subscribeDataChanged } from "../services/dataEvents";
|
||||||
import { logError } from "../services/logService";
|
import { logError } from "../services/logService";
|
||||||
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
|
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
|
||||||
|
import {
|
||||||
|
bannerMode,
|
||||||
|
latchCardPage,
|
||||||
|
nextStep as findNextStep,
|
||||||
|
type BannerMode,
|
||||||
|
} from "../utils/onboardingPath";
|
||||||
import { useEntitlement } from "./useEntitlement";
|
import { useEntitlement } from "./useEntitlement";
|
||||||
import type { OnboardingCounters } from "../shared/types";
|
import {
|
||||||
|
CHECKLIST_ITEM_KEYS,
|
||||||
|
type ChecklistItem,
|
||||||
|
type ChecklistItemKey,
|
||||||
|
type OnboardingCounters,
|
||||||
|
type OnboardingStatus,
|
||||||
|
} from "../shared/types";
|
||||||
|
|
||||||
// State of the getting-started checklist (#351). The reducer and its initial
|
// State of the getting-started path (#351, #396). One instance lives in
|
||||||
// state are exported so the state machine is tested without renderHook (same
|
// `OnboardingProvider` (AppShell) and is shared by every page. The reducer, the
|
||||||
// pattern as `licenseReducer`, `feedbackReducer`, `migrationReducer`).
|
// loader and the data-channel follower are exported so the state machine is
|
||||||
|
// tested without renderHook (same pattern as `licenseReducer`,
|
||||||
export type OnboardingChecklistStatus = "loading" | "ready" | "error";
|
// `performSubmitKey`, `revocationCheckLauncher`).
|
||||||
|
|
||||||
export interface OnboardingChecklistState {
|
export interface OnboardingChecklistState {
|
||||||
status: OnboardingChecklistStatus;
|
/** `error` only while no load ever succeeded; a failed re-read keeps `ready`. */
|
||||||
|
status: OnboardingStatus;
|
||||||
/** Null until the first successful load. */
|
/** Null until the first successful load. */
|
||||||
counters: OnboardingCounters | null;
|
counters: OnboardingCounters | null;
|
||||||
/** True when an import folder has been chosen (read with the counters). */
|
/** True when an import folder has been chosen (read with the counters). */
|
||||||
importFolderSet: boolean;
|
importFolderSet: boolean;
|
||||||
/** Dismissed by the user. Untouched by a load error. */
|
/** Hidden by the user or by rule D9. Untouched by a load error. */
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
/** Message of the last load error, null otherwise. */
|
/** Skipped steps, in checklist order. */
|
||||||
error: string | null;
|
skipped: ChecklistItemKey[];
|
||||||
|
/** 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;
|
||||||
|
/** The page that carries a step card (`latchCardPage`), null otherwise. */
|
||||||
|
cardPage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OnboardingChecklistAction =
|
export type OnboardingChecklistAction =
|
||||||
| { type: "LOAD_START" }
|
| { type: "LOAD_START"; seq: number }
|
||||||
| { type: "LOAD_DONE"; snapshot: OnboardingSnapshot }
|
| { type: "LOAD_DONE"; seq: number; snapshot: OnboardingSnapshot }
|
||||||
| { type: "LOAD_ERROR"; error: string }
|
| { type: "LOAD_ERROR"; seq: number }
|
||||||
| { type: "HIDE" }
|
| { type: "HIDE" }
|
||||||
| { type: "SHOW" };
|
| { type: "SHOW" }
|
||||||
|
| { type: "SKIP"; key: ChecklistItemKey }
|
||||||
|
| { type: "RESUME"; key: ChecklistItemKey }
|
||||||
|
| { type: "SET_CARD_PAGE"; cardPage: string | null };
|
||||||
|
|
||||||
export const initialOnboardingChecklistState: OnboardingChecklistState = {
|
export const initialOnboardingChecklistState: OnboardingChecklistState = {
|
||||||
status: "loading",
|
status: "loading",
|
||||||
counters: null,
|
counters: null,
|
||||||
importFolderSet: false,
|
importFolderSet: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
error: null,
|
skipped: [],
|
||||||
|
loadSeq: 0,
|
||||||
|
hiddenSetAt: 0,
|
||||||
|
skippedSetAt: 0,
|
||||||
|
cardPage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** `keys` plus `key`, kept in checklist order and without duplicates. */
|
||||||
|
function withStep(keys: readonly ChecklistItemKey[], key: ChecklistItemKey): ChecklistItemKey[] {
|
||||||
|
return CHECKLIST_ITEM_KEYS.filter((k) => k === key || keys.includes(k));
|
||||||
|
}
|
||||||
|
|
||||||
export function onboardingChecklistReducer(
|
export function onboardingChecklistReducer(
|
||||||
state: OnboardingChecklistState,
|
state: OnboardingChecklistState,
|
||||||
action: OnboardingChecklistAction,
|
action: OnboardingChecklistAction,
|
||||||
): OnboardingChecklistState {
|
): OnboardingChecklistState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "LOAD_START":
|
case "LOAD_START":
|
||||||
// The previous counters stay so a refresh does not blank the list.
|
// A re-read keeps what is on screen, `ready` included: only a path that
|
||||||
return { ...state, status: "loading", error: null };
|
// never loaded shows `loading`.
|
||||||
case "LOAD_DONE":
|
return {
|
||||||
|
...state,
|
||||||
|
loadSeq: action.seq,
|
||||||
|
status: state.status === "ready" ? "ready" : "loading",
|
||||||
|
};
|
||||||
|
case "LOAD_DONE": {
|
||||||
|
if (action.seq !== state.loadSeq) return state;
|
||||||
|
const { snapshot } = action;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
counters: action.snapshot.counters,
|
counters: snapshot.counters,
|
||||||
importFolderSet: action.snapshot.importFolderSet,
|
importFolderSet: snapshot.importFolderSet,
|
||||||
hidden: action.snapshot.hidden,
|
// A local action dispatched after this load started wins over its
|
||||||
error: null,
|
// 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,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case "LOAD_ERROR":
|
case "LOAD_ERROR":
|
||||||
// `hidden` is deliberately kept: a failed read must not resurface a
|
if (action.seq !== state.loadSeq) return state;
|
||||||
// checklist the user dismissed.
|
// After a first success the last state stays on screen; `hidden` is never
|
||||||
return { ...state, status: "error", error: action.error };
|
// touched, so a failed read cannot resurface a path the user dismissed.
|
||||||
|
return { ...state, status: state.status === "ready" ? "ready" : "error" };
|
||||||
case "HIDE":
|
case "HIDE":
|
||||||
return { ...state, hidden: true };
|
return { ...state, hidden: true, hiddenSetAt: state.loadSeq };
|
||||||
case "SHOW":
|
case "SHOW":
|
||||||
return { ...state, hidden: false };
|
return { ...state, hidden: false, hiddenSetAt: state.loadSeq };
|
||||||
|
case "SKIP":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
skipped: withStep(state.skipped, action.key),
|
||||||
|
skippedSetAt: state.loadSeq,
|
||||||
|
// "Skip" releases the card latch; the provider sets it again on the
|
||||||
|
// next render if the new next step is on the same page.
|
||||||
|
cardPage: null,
|
||||||
|
};
|
||||||
|
case "RESUME":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
skipped: state.skipped.filter((key) => key !== action.key),
|
||||||
|
skippedSetAt: state.loadSeq,
|
||||||
|
};
|
||||||
|
case "SET_CARD_PAGE":
|
||||||
|
return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,34 +141,191 @@ function errorMessage(e: unknown): string {
|
||||||
return e instanceof Error ? e.message : String(e);
|
return e instanceof Error ? e.message : String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OnboardingLoaderDeps {
|
||||||
|
initializeGuidedPath: () => Promise<{ hidden: boolean }>;
|
||||||
|
getOnboardingSnapshot: () => Promise<OnboardingSnapshot>;
|
||||||
|
dispatch: (action: OnboardingChecklistAction) => void;
|
||||||
|
logError: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Getting-started checklist of the dashboard: counters read from the profile
|
* The provider's load function (#396), kept outside React so it is tested
|
||||||
* database, dismissal preference, and the Budget entitlement that decides
|
* without a renderer:
|
||||||
* whether the sixth row is locked.
|
* - every load is numbered, and the reducer drops the outcome of any load but
|
||||||
|
* the last one started (a stale answer never overwrites a newer one);
|
||||||
|
* - the first load waits for rule D9 (`initializeGuidedPath`), run once per
|
||||||
|
* loader: the promise is shared, so the double mount of StrictMode does not
|
||||||
|
* run it twice. A failed initialization is logged and the load goes on, the
|
||||||
|
* path showing as stored; the flag stays unset, so the next opening retries;
|
||||||
|
* - a failed load is logged only when it starts a failure streak: repeated
|
||||||
|
* re-reads must not push a real cause out of the last log lines that a
|
||||||
|
* feedback report carries.
|
||||||
*/
|
*/
|
||||||
export function useOnboardingChecklist() {
|
export function createOnboardingLoader(deps: OnboardingLoaderDeps): () => Promise<void> {
|
||||||
|
let seq = 0;
|
||||||
|
let initialization: Promise<void> | null = null;
|
||||||
|
let failing = false;
|
||||||
|
|
||||||
|
const initializeOnce = (): Promise<void> => {
|
||||||
|
if (initialization === null) {
|
||||||
|
initialization = deps.initializeGuidedPath().then(
|
||||||
|
() => undefined,
|
||||||
|
(e: unknown) => {
|
||||||
|
deps.logError(
|
||||||
|
"Onboarding: failed to apply the first-run rule, the path shows as stored",
|
||||||
|
errorMessage(e),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return initialization;
|
||||||
|
};
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
const mine = ++seq;
|
||||||
|
deps.dispatch({ type: "LOAD_START", seq: mine });
|
||||||
|
await initializeOnce();
|
||||||
|
try {
|
||||||
|
const snapshot = await deps.getOnboardingSnapshot();
|
||||||
|
if (mine === seq) failing = false;
|
||||||
|
deps.dispatch({ type: "LOAD_DONE", seq: mine, snapshot });
|
||||||
|
} catch (e) {
|
||||||
|
if (mine === seq && !failing) {
|
||||||
|
failing = true;
|
||||||
|
deps.logError("Onboarding: failed to load the getting-started path", errorMessage(e));
|
||||||
|
}
|
||||||
|
deps.dispatch({ type: "LOAD_ERROR", seq: mine });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait after a data event before re-reading: a burst of writes costs one read (ADR 0022). */
|
||||||
|
export const DATA_CHANGE_DELAY_MS = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follows the data channel: `run` is called once `delayMs` passed without a
|
||||||
|
* new event. Returns the cleanup, which unsubscribes and drops a pending run.
|
||||||
|
*/
|
||||||
|
export function followDataChanges(
|
||||||
|
run: () => void,
|
||||||
|
delayMs: number = DATA_CHANGE_DELAY_MS,
|
||||||
|
): () => void {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const unsubscribe = subscribeDataChanged(() => {
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
run();
|
||||||
|
}, delayMs);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingViewInput {
|
||||||
|
state: OnboardingChecklistState;
|
||||||
|
checklist: Checklist | null;
|
||||||
|
pathname: string;
|
||||||
|
budgetReady: boolean;
|
||||||
|
/** Pages that carry a card: `CARD_PAGES` when omitted, tests pass their own. */
|
||||||
|
cardPages?: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingView {
|
||||||
|
nextStep: ChecklistItem | null;
|
||||||
|
/** The card latch for this render; the provider stores it when it changed. */
|
||||||
|
cardPage: string | null;
|
||||||
|
mode: BannerMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one render of the provider shows, from its state: the next step, the
|
||||||
|
* card latch and the mode. Pure, so the provider's render logic is tested
|
||||||
|
* without React.
|
||||||
|
*/
|
||||||
|
export function deriveOnboardingView({
|
||||||
|
state,
|
||||||
|
checklist,
|
||||||
|
pathname,
|
||||||
|
budgetReady,
|
||||||
|
cardPages,
|
||||||
|
}: OnboardingViewInput): OnboardingView {
|
||||||
|
const nextStep = checklist ? findNextStep(checklist) : null;
|
||||||
|
const cardPage = latchCardPage(state.cardPage, {
|
||||||
|
pathname,
|
||||||
|
nextStep,
|
||||||
|
hidden: state.hidden,
|
||||||
|
cardPages,
|
||||||
|
});
|
||||||
|
const mode = bannerMode({
|
||||||
|
status: state.status,
|
||||||
|
checklist,
|
||||||
|
hidden: state.hidden,
|
||||||
|
pathname,
|
||||||
|
isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0,
|
||||||
|
budgetReady,
|
||||||
|
cardPage,
|
||||||
|
});
|
||||||
|
return { nextStep, cardPage, mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the provider shares through `useOnboarding()`. */
|
||||||
|
export interface OnboardingValue {
|
||||||
|
status: OnboardingStatus;
|
||||||
|
checklist: Checklist | null;
|
||||||
|
/** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */
|
||||||
|
counters: OnboardingCounters | null;
|
||||||
|
hidden: boolean;
|
||||||
|
budgetReady: 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`). */
|
||||||
|
mode: BannerMode;
|
||||||
|
/** The page that carries a step card, null otherwise. */
|
||||||
|
cardPage: string | null;
|
||||||
|
hide: () => Promise<void>;
|
||||||
|
show: () => Promise<void>;
|
||||||
|
skip: (key: ChecklistItemKey) => Promise<void>;
|
||||||
|
resume: (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
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function useOnboardingChecklist(): OnboardingValue {
|
||||||
const [state, dispatch] = useReducer(
|
const [state, dispatch] = useReducer(
|
||||||
onboardingChecklistReducer,
|
onboardingChecklistReducer,
|
||||||
initialOnboardingChecklistState,
|
initialOnboardingChecklistState,
|
||||||
);
|
);
|
||||||
const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget");
|
const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget");
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
// One loader per provider instance: a profile switch remounts AppShell, so
|
||||||
dispatch({ type: "LOAD_START" });
|
// the new profile gets its own numbering and its own D9 initialization.
|
||||||
try {
|
const [refresh] = useState(() =>
|
||||||
const snapshot = await getOnboardingCounters();
|
createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
|
||||||
dispatch({ type: "LOAD_DONE", snapshot });
|
);
|
||||||
} catch (e) {
|
|
||||||
const error = errorMessage(e);
|
|
||||||
logError("Onboarding: failed to load checklist counters", error);
|
|
||||||
dispatch({ type: "LOAD_ERROR", error });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Read-only local SELECTs: a StrictMode double run is harmless.
|
// Read at mount, then again at every route change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh, pathname]);
|
||||||
|
|
||||||
|
// Re-read after the writes that move a counter, once a burst is over.
|
||||||
|
useEffect(() => followDataChanges(() => void refresh()), [refresh]);
|
||||||
|
|
||||||
const hide = useCallback(async () => {
|
const hide = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -122,28 +345,80 @@ export function useOnboardingChecklist() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const skip = useCallback(async (key: ChecklistItemKey) => {
|
||||||
|
try {
|
||||||
|
await skipStep(key);
|
||||||
|
dispatch({ type: "SKIP", key });
|
||||||
|
} catch (e) {
|
||||||
|
logError("Onboarding: failed to skip a step", errorMessage(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resume = useCallback(async (key: ChecklistItemKey) => {
|
||||||
|
try {
|
||||||
|
await resumeStep(key);
|
||||||
|
dispatch({ type: "RESUME", key });
|
||||||
|
} catch (e) {
|
||||||
|
logError("Onboarding: failed to resume a step", errorMessage(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const checklist: Checklist | null = useMemo(
|
const checklist: Checklist | null = useMemo(
|
||||||
() =>
|
() =>
|
||||||
state.counters
|
state.counters
|
||||||
? computeChecklist(state.counters, {
|
? computeChecklist(state.counters, {
|
||||||
importFolderSet: state.importFolderSet,
|
importFolderSet: state.importFolderSet,
|
||||||
budgetAllowed,
|
budgetAllowed,
|
||||||
|
skipped: state.skipped,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
[state.counters, state.importFolderSet, budgetAllowed],
|
[state.counters, state.importFolderSet, state.skipped, budgetAllowed],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const { nextStep, cardPage, mode } = deriveOnboardingView({
|
||||||
|
state,
|
||||||
|
checklist,
|
||||||
|
pathname,
|
||||||
|
budgetReady,
|
||||||
|
});
|
||||||
|
// The card latch is derived during render, then stored: an effect would
|
||||||
|
// leave one frame showing the banner on a page that carries a card. The
|
||||||
|
// update is for this component's own reducer, which React applies before
|
||||||
|
// committing; `latchCardPage` is idempotent, so it settles in one pass.
|
||||||
|
if (cardPage !== state.cardPage) {
|
||||||
|
dispatch({ type: "SET_CARD_PAGE", cardPage });
|
||||||
|
}
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
status: state.status,
|
status: state.status,
|
||||||
checklist,
|
checklist,
|
||||||
/** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */
|
|
||||||
counters: state.counters,
|
counters: state.counters,
|
||||||
hidden: state.hidden,
|
hidden: state.hidden,
|
||||||
/** Message of the last load error, null otherwise. */
|
|
||||||
error: state.error,
|
|
||||||
budgetReady,
|
budgetReady,
|
||||||
refresh,
|
nextStep,
|
||||||
|
mode,
|
||||||
|
cardPage,
|
||||||
hide,
|
hide,
|
||||||
show,
|
show,
|
||||||
};
|
skip,
|
||||||
|
resume,
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
state.status,
|
||||||
|
checklist,
|
||||||
|
state.counters,
|
||||||
|
state.hidden,
|
||||||
|
budgetReady,
|
||||||
|
nextStep,
|
||||||
|
mode,
|
||||||
|
cardPage,
|
||||||
|
hide,
|
||||||
|
show,
|
||||||
|
skip,
|
||||||
|
resume,
|
||||||
|
refresh,
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
202
src/hooks/useOnboardingChecklist.wiring.test.ts
Normal file
202
src/hooks/useOnboardingChecklist.wiring.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
// useOnboardingChecklist — the hook's own wiring (#396).
|
||||||
|
//
|
||||||
|
// The reducer, the loader, the follower and the derivation are tested in
|
||||||
|
// useOnboardingChecklist.test.ts; renderToStaticMarkup runs no effect, so what
|
||||||
|
// the hook does with React would stay unguarded. Here React's hooks are
|
||||||
|
// stubbed (same technique as useImportWizard.executeImport.test.ts): the hook
|
||||||
|
// runs as a plain function on a prepared state, its effects are captured and
|
||||||
|
// run by hand, and the onboarding service is mocked.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
|
||||||
|
|
||||||
|
const harness = vi.hoisted(() => ({
|
||||||
|
state: null as unknown,
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
effects: [] as Effect[],
|
||||||
|
pathname: "/",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useReducer: () => [harness.state, harness.dispatch],
|
||||||
|
useState: (init: unknown) => [typeof init === "function" ? (init as () => unknown)() : init, () => undefined],
|
||||||
|
useCallback: (fn: unknown) => fn,
|
||||||
|
useMemo: (fn: () => unknown) => fn(),
|
||||||
|
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
|
||||||
|
harness.effects.push({ fn, deps });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: harness.pathname }) }));
|
||||||
|
vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) }));
|
||||||
|
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
|
||||||
|
vi.mock("../services/onboardingService", () => ({
|
||||||
|
getOnboardingSnapshot: vi.fn(),
|
||||||
|
initializeGuidedPath: vi.fn(),
|
||||||
|
hideChecklist: vi.fn(),
|
||||||
|
showChecklist: vi.fn(),
|
||||||
|
skipStep: vi.fn(),
|
||||||
|
resumeStep: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
useOnboardingChecklist,
|
||||||
|
initialOnboardingChecklistState,
|
||||||
|
onboardingChecklistReducer,
|
||||||
|
type OnboardingChecklistState,
|
||||||
|
} from "./useOnboardingChecklist";
|
||||||
|
import { notifyDataChanged } from "../services/dataEvents";
|
||||||
|
import { logError } from "../services/logService";
|
||||||
|
import {
|
||||||
|
getOnboardingSnapshot,
|
||||||
|
hideChecklist,
|
||||||
|
initializeGuidedPath,
|
||||||
|
resumeStep,
|
||||||
|
showChecklist,
|
||||||
|
skipStep,
|
||||||
|
type OnboardingSnapshot,
|
||||||
|
} from "../services/onboardingService";
|
||||||
|
|
||||||
|
const snapshot: OnboardingSnapshot = {
|
||||||
|
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
|
||||||
|
importFolderSet: true,
|
||||||
|
hidden: false,
|
||||||
|
skipped: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ready: OnboardingChecklistState = [
|
||||||
|
{ type: "LOAD_START", seq: 1 } as const,
|
||||||
|
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
|
||||||
|
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
|
||||||
|
|
||||||
|
/** Lets the loader's promises settle. */
|
||||||
|
const flush = async () => {
|
||||||
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionsOfType = (type: string) =>
|
||||||
|
harness.dispatch.mock.calls.map((c) => c[0]).filter((a) => a.type === type);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.state = initialOnboardingChecklistState;
|
||||||
|
harness.pathname = "/";
|
||||||
|
harness.effects = [];
|
||||||
|
harness.dispatch.mockReset();
|
||||||
|
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
|
||||||
|
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
|
||||||
|
for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) {
|
||||||
|
vi.mocked(write).mockReset().mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
|
vi.mocked(logError).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useOnboardingChecklist — wiring (#396)", () => {
|
||||||
|
it("reads at mount, through rule D9, and again whenever the route changes", async () => {
|
||||||
|
harness.pathname = "/import";
|
||||||
|
useOnboardingChecklist();
|
||||||
|
const [reread] = harness.effects;
|
||||||
|
// The route is a dependency: React runs the read again on every new pathname.
|
||||||
|
expect(reread.deps).toContain("/import");
|
||||||
|
|
||||||
|
reread.fn();
|
||||||
|
await flush();
|
||||||
|
expect(initializeGuidedPath).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_DONE", seq: 1, snapshot },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads 150 ms after a burst of data events, and its cleanup unsubscribes", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
useOnboardingChecklist();
|
||||||
|
const follow = harness.effects[1];
|
||||||
|
expect(follow.deps).not.toContain(harness.pathname);
|
||||||
|
|
||||||
|
const cleanup = follow.fn() as () => void;
|
||||||
|
notifyDataChanged();
|
||||||
|
notifyDataChanged();
|
||||||
|
vi.advanceTimersByTime(149);
|
||||||
|
expect(actionsOfType("LOAD_START")).toHaveLength(0);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(actionsOfType("LOAD_START")).toHaveLength(1);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
notifyDataChanged();
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(actionsOfType("LOAD_START")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a card latch that changed during render, and only then", () => {
|
||||||
|
harness.state = { ...ready, cardPage: "/import" };
|
||||||
|
harness.pathname = "/reports";
|
||||||
|
useOnboardingChecklist();
|
||||||
|
expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_CARD_PAGE", cardPage: null });
|
||||||
|
|
||||||
|
harness.dispatch.mockReset();
|
||||||
|
harness.state = ready;
|
||||||
|
useOnboardingChecklist();
|
||||||
|
expect(harness.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares the derived next step and mode with the state", () => {
|
||||||
|
harness.state = ready;
|
||||||
|
harness.pathname = "/reports";
|
||||||
|
const value = useOnboardingChecklist();
|
||||||
|
expect(value.status).toBe("ready");
|
||||||
|
expect(value.nextStep?.key).toBe("manualCategory");
|
||||||
|
expect(value.mode).toBe("banner");
|
||||||
|
expect(value.cardPage).toBeNull();
|
||||||
|
expect(value.checklist?.done).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each action writes its preference first, then updates the state", async () => {
|
||||||
|
harness.state = ready;
|
||||||
|
const value = useOnboardingChecklist();
|
||||||
|
await value.hide();
|
||||||
|
await value.show();
|
||||||
|
await value.skip("budget");
|
||||||
|
await value.resume("budget");
|
||||||
|
|
||||||
|
expect(hideChecklist).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showChecklist).toHaveBeenCalledTimes(1);
|
||||||
|
expect(skipStep).toHaveBeenCalledWith("budget");
|
||||||
|
expect(resumeStep).toHaveBeenCalledWith("budget");
|
||||||
|
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
{ type: "HIDE" },
|
||||||
|
{ type: "SHOW" },
|
||||||
|
{ type: "SKIP", key: "budget" },
|
||||||
|
{ type: "RESUME", key: "budget" },
|
||||||
|
]);
|
||||||
|
const hideDispatch = harness.dispatch.mock.invocationCallOrder[0];
|
||||||
|
expect(vi.mocked(hideChecklist).mock.invocationCallOrder[0]).toBeLessThan(hideDispatch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an action whose write fails leaves the state alone and logs", async () => {
|
||||||
|
harness.state = ready;
|
||||||
|
const value = useOnboardingChecklist();
|
||||||
|
vi.mocked(hideChecklist).mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
vi.mocked(skipStep).mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await value.hide();
|
||||||
|
await value.skip("source");
|
||||||
|
expect(harness.dispatch).not.toHaveBeenCalled();
|
||||||
|
expect(logError).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh is the loader itself", async () => {
|
||||||
|
const value = useOnboardingChecklist();
|
||||||
|
await value.refresh();
|
||||||
|
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(actionsOfType("LOAD_DONE")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/pages/DashboardPage.render.test.tsx
Normal file
143
src/pages/DashboardPage.render.test.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// DashboardPage reads the shared getting-started path (#396). No jsdom:
|
||||||
|
// react-dom/server renders in vitest's node environment. The path comes from a
|
||||||
|
// fake `useOnboarding()`, and the dashboard's data hook and widgets are stubbed
|
||||||
|
// to markers: what is pinned is the page's branching on the path, unchanged
|
||||||
|
// since #352 except that its source is now the provider.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "fr" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let onboarding: OnboardingValue;
|
||||||
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => onboarding }));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useDashboard", () => ({
|
||||||
|
useDashboard: () => ({
|
||||||
|
state: {
|
||||||
|
categoryBreakdown: [],
|
||||||
|
categoryOverTime: { categories: [], data: [] },
|
||||||
|
cartesSnapshot: null,
|
||||||
|
period: "year",
|
||||||
|
customDateFrom: "2026-01-01",
|
||||||
|
customDateTo: "2026-10-08",
|
||||||
|
referenceYear: 2026,
|
||||||
|
referenceMonth: 9,
|
||||||
|
accountIds: [],
|
||||||
|
accounts: [],
|
||||||
|
netWorth: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
setPeriod: vi.fn(),
|
||||||
|
setCustomDates: vi.fn(),
|
||||||
|
setReferencePeriod: vi.fn(),
|
||||||
|
setAccountIds: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Hoisted with the mocks below, which run before the imports.
|
||||||
|
const { marker } = vi.hoisted(() => ({
|
||||||
|
marker: (name: string) => ({
|
||||||
|
default: (props: { mode?: string }) => <i data-marker={name} data-mode={props.mode} />,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
vi.mock("../components/dashboard/CategoriesV1DiscoveryBanner", () => marker("discovery"));
|
||||||
|
vi.mock("../components/dashboard/DashboardEmptyState", () => marker("empty-state"));
|
||||||
|
vi.mock("../components/dashboard/OnboardingChecklist", () => marker("checklist"));
|
||||||
|
vi.mock("../components/dashboard/PeriodSelector", () => marker("period"));
|
||||||
|
vi.mock("../components/dashboard/NetWorthTile", () => marker("net-worth"));
|
||||||
|
vi.mock("../components/reports/CategoryBarChart", () => marker("bar-chart"));
|
||||||
|
vi.mock("../components/reports/CategoryOverTimeChart", () => marker("over-time"));
|
||||||
|
vi.mock("../components/reports/FilterPanel", () => marker("filters"));
|
||||||
|
vi.mock("../components/reports/cards/KpiCard", () => marker("kpi"));
|
||||||
|
vi.mock("../components/reports/cards/IncomeExpenseOverlayChart", () => marker("overlay"));
|
||||||
|
vi.mock("../components/reports/cards/TopMoversList", () => marker("movers"));
|
||||||
|
vi.mock("../components/reports/cards/BudgetAdherenceCard", () => marker("adherence"));
|
||||||
|
vi.mock("../components/shared/TransactionDetailModal", () => marker("detail"));
|
||||||
|
|
||||||
|
import DashboardPage from "./DashboardPage";
|
||||||
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||||
|
import type { OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
|
const counters = (transactions: number): OnboardingCounters => ({
|
||||||
|
sources: transactions > 0 ? 1 : 0,
|
||||||
|
imports: transactions > 0 ? 1 : 0,
|
||||||
|
manualCategorized: 0,
|
||||||
|
budgetEntries: 0,
|
||||||
|
transactions,
|
||||||
|
});
|
||||||
|
|
||||||
|
function path(overrides: Partial<OnboardingValue> & { transactions?: number } = {}): OnboardingValue {
|
||||||
|
const { transactions = 25, ...rest } = overrides;
|
||||||
|
const c = counters(transactions);
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
checklist: computeChecklist(c, { importFolderSet: true, budgetAllowed: true }),
|
||||||
|
counters: c,
|
||||||
|
hidden: false,
|
||||||
|
budgetReady: true,
|
||||||
|
nextStep: null,
|
||||||
|
mode: "banner",
|
||||||
|
cardPage: null,
|
||||||
|
hide: vi.fn(async () => undefined),
|
||||||
|
show: vi.fn(async () => undefined),
|
||||||
|
skip: vi.fn(async () => undefined),
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
refresh: vi.fn(async () => undefined),
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = () =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
onboarding = path();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DashboardPage on the shared path (#352, #396)", () => {
|
||||||
|
it("renders only its header while the path first loads", () => {
|
||||||
|
onboarding = path({ status: "loading", checklist: null, counters: null });
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("dashboard.title");
|
||||||
|
expect(html).not.toContain('data-marker="empty-state"');
|
||||||
|
expect(html).not.toContain('data-marker="filters"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the welcome card on a profile without any transaction", () => {
|
||||||
|
onboarding = path({ transactions: 0 });
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain('data-marker="empty-state"');
|
||||||
|
expect(html).not.toContain('data-marker="filters"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the compact checklist above the widgets on a profile with transactions", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain('data-marker="checklist" data-mode="compact"');
|
||||||
|
expect(html).toContain('data-marker="filters"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the compact checklist of a hidden path (a profile already active, D9)", () => {
|
||||||
|
onboarding = path({ hidden: true, mode: "none" });
|
||||||
|
const html = render();
|
||||||
|
expect(html).not.toContain('data-marker="checklist"');
|
||||||
|
expect(html).toContain('data-marker="filters"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the page without the list when the first load failed", () => {
|
||||||
|
onboarding = path({ status: "error", checklist: null, counters: null, mode: "none" });
|
||||||
|
const html = render();
|
||||||
|
expect(html).not.toContain('data-marker="checklist"');
|
||||||
|
expect(html).not.toContain('data-marker="empty-state"');
|
||||||
|
expect(html).toContain('data-marker="filters"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,13 +14,15 @@
|
||||||
// profile without any transaction gets `DashboardEmptyState` instead of the
|
// profile without any transaction gets `DashboardEmptyState` instead of the
|
||||||
// widgets, outside the `isLoading` veil so its link stays clickable. With
|
// widgets, outside the `isLoading` veil so its link stays clickable. With
|
||||||
// transactions, the compact checklist sits between the title and the filter
|
// transactions, the compact checklist sits between the title and the filter
|
||||||
// panel until it is complete or dismissed. A load error falls back to the
|
// panel until it is complete or dismissed. A first load that fails falls back
|
||||||
// v0.15.0 page without the list.
|
// to the v0.15.0 page without the list; the provider logs it.
|
||||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
//
|
||||||
|
// Since #396 the checklist comes from `OnboardingProvider` (AppShell), shared
|
||||||
|
// by every page and re-read live, instead of a load of its own at mount.
|
||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDashboard } from "../hooks/useDashboard";
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
import { useOnboardingChecklist } from "../hooks/useOnboardingChecklist";
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||||
import { logWarn } from "../services/logService";
|
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
|
|
@ -48,13 +50,7 @@ export default function DashboardPage() {
|
||||||
setAccountIds,
|
setAccountIds,
|
||||||
} = useDashboard();
|
} = useDashboard();
|
||||||
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
|
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
|
||||||
const onboarding = useOnboardingChecklist();
|
const onboarding = useOnboarding();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (onboarding.status === "error") {
|
|
||||||
logWarn("Dashboard: getting-started checklist unavailable, rendering without it", onboarding.error);
|
|
||||||
}
|
|
||||||
}, [onboarding.status, onboarding.error]);
|
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
|
import {
|
||||||
|
getBudgetVsActualData,
|
||||||
|
getActualTotalsForYear,
|
||||||
|
upsertBudgetEntry,
|
||||||
|
deleteBudgetEntry,
|
||||||
|
upsertBudgetEntriesForYear,
|
||||||
|
applyTemplate,
|
||||||
|
saveAsTemplate,
|
||||||
|
} from "./budgetService";
|
||||||
|
import { subscribeDataChanged } from "./dataEvents";
|
||||||
|
|
||||||
vi.mock("./db", () => {
|
vi.mock("./db", () => {
|
||||||
const getDb = vi.fn();
|
const getDb = vi.fn();
|
||||||
|
|
@ -9,11 +18,13 @@ vi.mock("./db", () => {
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
const mockSelect = vi.fn();
|
||||||
const mockDb = { select: mockSelect };
|
const mockExecute = vi.fn();
|
||||||
|
const mockDb = { select: mockSelect, execute: mockExecute };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
mockSelect.mockReset();
|
mockSelect.mockReset();
|
||||||
|
mockExecute.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
// getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories,
|
// getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories,
|
||||||
|
|
@ -118,3 +129,87 @@ describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => {
|
||||||
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]);
|
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("budget writes — data-change notification (#396)", () => {
|
||||||
|
/** Number of `execute` calls already made at each data-change notification. */
|
||||||
|
const notified: number[] = [];
|
||||||
|
let unsubscribe: () => void = () => undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notified.length = 0;
|
||||||
|
unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertBudgetEntry notifies once after the write, and not when it fails", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await upsertBudgetEntry(4, 2026, 10, 250);
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(upsertBudgetEntry(4, 2026, 11, 250)).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteBudgetEntry notifies once after the delete, and not when it fails", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await deleteBudgetEntry(4, 2026, 10);
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(deleteBudgetEntry(4, 2026, 10)).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertBudgetEntriesForYear notifies once, after the twelve months", async () => {
|
||||||
|
mockExecute.mockResolvedValue({ rowsAffected: 1 });
|
||||||
|
await upsertBudgetEntriesForYear(4, 2026, [100, 0, 100, 0, 100, 0, 100, 0, 100, 0, 100, 0]);
|
||||||
|
expect(mockExecute).toHaveBeenCalledTimes(12);
|
||||||
|
expect(notified).toEqual([12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertBudgetEntriesForYear does not notify when a month fails", async () => {
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1 })
|
||||||
|
.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(upsertBudgetEntriesForYear(4, 2026, Array(12).fill(100))).rejects.toThrow(
|
||||||
|
"database is locked",
|
||||||
|
);
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applyTemplate notifies once, after the template's last entry", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ id: 1, template_id: 7, category_id: 4, amount: 100 },
|
||||||
|
{ id: 2, template_id: 7, category_id: 5, amount: 200 },
|
||||||
|
{ id: 3, template_id: 7, category_id: 6, amount: 300 },
|
||||||
|
]);
|
||||||
|
mockExecute.mockResolvedValue({ rowsAffected: 1 });
|
||||||
|
await applyTemplate(7, 2026, 10);
|
||||||
|
expect(mockExecute).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockExecute.mock.calls.map((c) => (c[1] as unknown[])[0])).toEqual([4, 5, 6]);
|
||||||
|
expect(mockExecute.mock.calls[0][1]).toEqual([4, 2026, 10, 100, null]);
|
||||||
|
expect(notified).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applyTemplate does not notify when an entry fails", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ id: 1, template_id: 7, category_id: 4, amount: 100 },
|
||||||
|
{ id: 2, template_id: 7, category_id: 5, amount: 200 },
|
||||||
|
]);
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1 })
|
||||||
|
.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(applyTemplate(7, 2026, 10)).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving a template changes no counter: saveAsTemplate stays silent", async () => {
|
||||||
|
mockExecute.mockResolvedValue({ rowsAffected: 1, lastInsertId: 9 });
|
||||||
|
await saveAsTemplate("Mensuel", undefined, [{ category_id: 4, amount: 100 }]);
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import type Database from "@tauri-apps/plugin-sql";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import { inPlaceholders } from "../utils/sqlFilters";
|
import { inPlaceholders } from "../utils/sqlFilters";
|
||||||
import type {
|
import type {
|
||||||
Category,
|
Category,
|
||||||
|
|
@ -40,6 +42,26 @@ export async function getBudgetEntriesForMonth(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every budget write below is announced on the data channel once it succeeded:
|
||||||
|
// the number of budget entries is a getting-started counter (ADR 0022).
|
||||||
|
|
||||||
|
/** The UPSERT behind `upsertBudgetEntry` and `applyTemplate`, without the announcement. */
|
||||||
|
async function writeBudgetEntry(
|
||||||
|
db: Database,
|
||||||
|
categoryId: number,
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
amount: number,
|
||||||
|
notes?: string
|
||||||
|
): Promise<void> {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO budget_entries (category_id, year, month, amount, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, notes = $5, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[categoryId, year, month, amount, notes || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function upsertBudgetEntry(
|
export async function upsertBudgetEntry(
|
||||||
categoryId: number,
|
categoryId: number,
|
||||||
year: number,
|
year: number,
|
||||||
|
|
@ -48,12 +70,8 @@ export async function upsertBudgetEntry(
|
||||||
notes?: string
|
notes?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db.execute(
|
await writeBudgetEntry(db, categoryId, year, month, amount, notes);
|
||||||
`INSERT INTO budget_entries (category_id, year, month, amount, notes)
|
notifyDataChanged();
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, notes = $5, updated_at = CURRENT_TIMESTAMP`,
|
|
||||||
[categoryId, year, month, amount, notes || null]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBudgetEntry(
|
export async function deleteBudgetEntry(
|
||||||
|
|
@ -66,6 +84,7 @@ export async function deleteBudgetEntry(
|
||||||
"DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3",
|
"DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3",
|
||||||
[categoryId, year, month]
|
[categoryId, year, month]
|
||||||
);
|
);
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActualsByCategory(
|
export async function getActualsByCategory(
|
||||||
|
|
@ -116,6 +135,7 @@ export async function upsertBudgetEntriesForYear(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
|
|
@ -165,9 +185,12 @@ export async function applyTemplate(
|
||||||
month: number
|
month: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const entries = await getTemplateEntries(templateId);
|
const entries = await getTemplateEntries(templateId);
|
||||||
|
const db = await getDb();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
await upsertBudgetEntry(entry.category_id, year, month, entry.amount);
|
await writeBudgetEntry(db, entry.category_id, year, month, entry.amount);
|
||||||
}
|
}
|
||||||
|
// One announcement for the whole template, after its last entry.
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTemplate(templateId: number): Promise<void> {
|
export async function deleteTemplate(templateId: number): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
validateKeyword,
|
validateKeyword,
|
||||||
previewKeywordMatches,
|
previewKeywordMatches,
|
||||||
applyKeywordWithReassignment,
|
applyKeywordWithReassignment,
|
||||||
KEYWORD_MAX_LENGTH,
|
KEYWORD_MAX_LENGTH,
|
||||||
} from "./categorizationService";
|
} from "./categorizationService";
|
||||||
|
import { subscribeDataChanged } from "./dataEvents";
|
||||||
|
|
||||||
vi.mock("./db", () => {
|
vi.mock("./db", () => {
|
||||||
const getDb = vi.fn();
|
const getDb = vi.fn();
|
||||||
|
|
@ -18,7 +19,7 @@ vi.mock("./db", () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { getDb } from "./db";
|
import { getDb, withTransaction } from "./db";
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
const mockSelect = vi.fn();
|
||||||
const mockExecute = vi.fn();
|
const mockExecute = vi.fn();
|
||||||
|
|
@ -180,3 +181,63 @@ describe("applyKeywordWithReassignment", () => {
|
||||||
expect(mockExecute).not.toHaveBeenCalled();
|
expect(mockExecute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("applyKeywordWithReassignment — data-change notification (#396)", () => {
|
||||||
|
const input = {
|
||||||
|
keyword: "METRO",
|
||||||
|
categoryId: 3,
|
||||||
|
transactionIds: [1, 2],
|
||||||
|
allowReplaceExisting: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** At each notification: the last statement issued, and whether the DB lock was held. */
|
||||||
|
const seen: Array<{ last: string; inLock: boolean }> = [];
|
||||||
|
let inLock = false;
|
||||||
|
let unsubscribe: () => void = () => undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seen.length = 0;
|
||||||
|
inLock = false;
|
||||||
|
vi.mocked(withTransaction).mockImplementation(async (fn) => {
|
||||||
|
inLock = true;
|
||||||
|
try {
|
||||||
|
return await fn((await getDb()) as never);
|
||||||
|
} finally {
|
||||||
|
inLock = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unsubscribe = subscribeDataChanged(() => {
|
||||||
|
const calls = mockExecute.mock.calls;
|
||||||
|
seen.push({ last: String(calls[calls.length - 1]?.[0]).trim(), inLock });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribe();
|
||||||
|
vi.mocked(withTransaction).mockImplementation(async (fn) => fn((await getDb()) as never));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies once, after the COMMIT and once the DB lock is released", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
mockExecute.mockResolvedValue({ lastInsertId: 77, rowsAffected: 1 });
|
||||||
|
await applyKeywordWithReassignment(input);
|
||||||
|
expect(seen).toEqual([{ last: "COMMIT", inLock: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify after a ROLLBACK", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ lastInsertId: 77 }) // INSERT keywords
|
||||||
|
.mockRejectedValueOnce(new Error("disk full")); // UPDATE transactions fails
|
||||||
|
await expect(applyKeywordWithReassignment(input)).rejects.toThrow("disk full");
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify for a refused keyword", async () => {
|
||||||
|
await expect(applyKeywordWithReassignment({ ...input, keyword: "a" })).rejects.toThrow(
|
||||||
|
"invalid_keyword:tooShort",
|
||||||
|
);
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb, withTransaction } from "./db";
|
import { getDb, withTransaction } from "./db";
|
||||||
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import type Database from "@tauri-apps/plugin-sql";
|
import type Database from "@tauri-apps/plugin-sql";
|
||||||
import type { Keyword, RecentTransaction } from "../shared/types";
|
import type { Keyword, RecentTransaction } from "../shared/types";
|
||||||
|
|
||||||
|
|
@ -223,9 +224,14 @@ export async function applyKeywordWithReassignment(
|
||||||
}
|
}
|
||||||
const keyword = validation.value;
|
const keyword = validation.value;
|
||||||
|
|
||||||
return withTransaction((db) =>
|
const result = await withTransaction((db) =>
|
||||||
applyKeywordInTransaction(db, input, keyword),
|
applyKeywordInTransaction(db, input, keyword),
|
||||||
);
|
);
|
||||||
|
// Reached only after the COMMIT (a ROLLBACK rethrows), and once the DB lock
|
||||||
|
// is released: the recategorised transactions carry the manual flag that the
|
||||||
|
// "categorize by hand" step counts (ADR 0022).
|
||||||
|
notifyDataChanged();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyKeywordInTransaction(
|
async function applyKeywordInTransaction(
|
||||||
|
|
|
||||||
89
src/services/dataEvents.test.ts
Normal file
89
src/services/dataEvents.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./logService", () => ({
|
||||||
|
logInfo: vi.fn(),
|
||||||
|
logWarn: vi.fn(),
|
||||||
|
logError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { logError } from "./logService";
|
||||||
|
import { notifyDataChanged, subscribeDataChanged } from "./dataEvents";
|
||||||
|
|
||||||
|
// The channel is module state: every test unsubscribes what it subscribed.
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
function subscribe(listener: () => void): () => void {
|
||||||
|
const unsubscribe = subscribeDataChanged(listener);
|
||||||
|
cleanups.push(unsubscribe);
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(logError).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanups.splice(0).forEach((unsubscribe) => unsubscribe());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dataEvents (#396)", () => {
|
||||||
|
it("a subscriber receives each notification", () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
subscribe(listener);
|
||||||
|
notifyDataChanged();
|
||||||
|
notifyDataChanged();
|
||||||
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unsubscribed listener no longer receives", () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = subscribe(listener);
|
||||||
|
notifyDataChanged();
|
||||||
|
unsubscribe();
|
||||||
|
notifyDataChanged();
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribing one listener leaves the others subscribed", () => {
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
const unsubscribeFirst = subscribe(first);
|
||||||
|
subscribe(second);
|
||||||
|
unsubscribeFirst();
|
||||||
|
notifyDataChanged();
|
||||||
|
expect(first).not.toHaveBeenCalled();
|
||||||
|
expect(second).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a subscriber that throws does not stop the others, and notify never throws", () => {
|
||||||
|
const before = vi.fn();
|
||||||
|
const after = vi.fn();
|
||||||
|
subscribe(before);
|
||||||
|
subscribe(() => {
|
||||||
|
throw new Error("listener bug");
|
||||||
|
});
|
||||||
|
subscribe(after);
|
||||||
|
|
||||||
|
expect(() => notifyDataChanged()).not.toThrow();
|
||||||
|
expect(before).toHaveBeenCalledTimes(1);
|
||||||
|
expect(after).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(logError).mock.calls[0]).toEqual([
|
||||||
|
"Data events: a subscriber failed",
|
||||||
|
"listener bug",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a listener that unsubscribes while being called does not skip the next one", () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const unsubscribeSelf: { fn: () => void } = { fn: () => undefined };
|
||||||
|
unsubscribeSelf.fn = subscribe(() => unsubscribeSelf.fn());
|
||||||
|
subscribe(next);
|
||||||
|
notifyDataChanged();
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies nobody without subscribers", () => {
|
||||||
|
expect(() => notifyDataChanged()).not.toThrow();
|
||||||
|
expect(logError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/services/dataEvents.ts
Normal file
45
src/services/dataEvents.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { logError } from "./logService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-change channel (#396, ADR 0022).
|
||||||
|
*
|
||||||
|
* The writes that may move a counter read elsewhere (the getting-started path
|
||||||
|
* reads `COUNTERS_SQL` and the import folder) call `notifyDataChanged()` once
|
||||||
|
* they have succeeded. The message only says "data that counters read may have
|
||||||
|
* changed": it carries no payload, and the data layer does not know who
|
||||||
|
* listens. Subscribers re-read on their own terms (the onboarding provider
|
||||||
|
* waits 150 ms to group a burst, then ignores any stale answer).
|
||||||
|
*
|
||||||
|
* Modeled on `logService.subscribe`, without React.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type DataChangedListener = () => void;
|
||||||
|
|
||||||
|
const listeners = new Set<DataChangedListener>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells every subscriber that data read by counters may have changed. Never
|
||||||
|
* throws: a subscriber that fails is logged and the others still run, so a
|
||||||
|
* write that succeeded is never reported as failed because of a listener.
|
||||||
|
*/
|
||||||
|
export function notifyDataChanged(): void {
|
||||||
|
// Iterate over a copy: a listener may unsubscribe while being called.
|
||||||
|
for (const listener of [...listeners]) {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch (e) {
|
||||||
|
logError(
|
||||||
|
"Data events: a subscriber failed",
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribes to data changes. Returns the function that unsubscribes. */
|
||||||
|
export function subscribeDataChanged(listener: DataChangedListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
* transaction exists to survive.
|
* transaction exists to survive.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("./db", () => {
|
vi.mock("./db", () => {
|
||||||
const getDb = vi.fn();
|
const getDb = vi.fn();
|
||||||
|
|
@ -49,6 +49,7 @@ import {
|
||||||
type ExportImportSource,
|
type ExportImportSource,
|
||||||
type ExportImportTemplate,
|
type ExportImportTemplate,
|
||||||
} from "./dataExportService";
|
} from "./dataExportService";
|
||||||
|
import { subscribeDataChanged } from "./dataEvents";
|
||||||
import { formatFromRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat";
|
import { formatFromRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat";
|
||||||
import type { Category } from "../shared/types";
|
import type { Category } from "../shared/types";
|
||||||
import fr from "../i18n/locales/fr.json";
|
import fr from "../i18n/locales/fr.json";
|
||||||
|
|
@ -762,3 +763,80 @@ describe("a restore writes the manual flag as an integer (#390)", () => {
|
||||||
expect(transactions.map((t) => t.is_manually_categorized)).toEqual([1, 1, 0]);
|
expect(transactions.map((t) => t.is_manually_categorized)).toEqual([1, 1, 0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 8. A committed restore is announced on the data channel (#396, ADR 0022)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("a committed restore is announced on the data channel (#396)", () => {
|
||||||
|
/** The statements already issued at each notification. */
|
||||||
|
const seen: string[][] = [];
|
||||||
|
let unsubscribe: () => void = () => undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seen.length = 0;
|
||||||
|
unsubscribe = subscribeDataChanged(() => seen.push([...db.log]));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transactions with categories: once, after the COMMIT", async () => {
|
||||||
|
await importTransactionsWithCategories(
|
||||||
|
envelopeData({ transactions: [transactionFixture(1)] }),
|
||||||
|
"backup.json"
|
||||||
|
);
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0][seen[0].length - 1]).toBe("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transactions only: once, after the COMMIT", async () => {
|
||||||
|
await importTransactionsOnly(envelopeData({ transactions: [transactionFixture(1)] }), "backup.json");
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0][seen[0].length - 1]).toBe("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("categories only: once, after the COMMIT", async () => {
|
||||||
|
await importCategoriesOnly({ categories: CATEGORY_FIXTURES, suppliers: [], keywords: [] });
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0][seen[0].length - 1]).toBe("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a restore rolled back announces nothing", async () => {
|
||||||
|
let txInserts = 0;
|
||||||
|
wire(
|
||||||
|
makeFakeDb((sql) => {
|
||||||
|
if (!/INSERT INTO transactions/.test(sql)) return false;
|
||||||
|
return ++txInserts === 2;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
importTransactionsOnly(
|
||||||
|
envelopeData({ transactions: [transactionFixture(1), transactionFixture(2)] }),
|
||||||
|
"backup.json"
|
||||||
|
)
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
expect(db.log).toContain("ROLLBACK");
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed COMMIT announces nothing", async () => {
|
||||||
|
wire(makeFakeDb((sql) => sql.trim() === "COMMIT"));
|
||||||
|
await expect(
|
||||||
|
importTransactionsOnly(envelopeData({ transactions: [transactionFixture(1)] }), "backup.json")
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a backup refused at the boundary announces nothing", async () => {
|
||||||
|
await expect(
|
||||||
|
importTransactionsWithCategories(
|
||||||
|
envelopeData({ import_sources: [{ ...SOURCE, amount_mode: "nonsense" as never }] }),
|
||||||
|
"backup.json"
|
||||||
|
)
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(db.log).toEqual([]);
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type Database from "@tauri-apps/plugin-sql";
|
import type Database from "@tauri-apps/plugin-sql";
|
||||||
import { getDb, withTransaction } from "./db";
|
import { getDb, withTransaction } from "./db";
|
||||||
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import Papa from "papaparse";
|
import Papa from "papaparse";
|
||||||
import { toSqlFlag } from "../utils/sqlFlag";
|
import { toSqlFlag } from "../utils/sqlFlag";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -414,11 +415,16 @@ const HOST_SOURCE_MAPPING = JSON.stringify({
|
||||||
* to go back to. `withTransaction` holds the single DB lock for the whole
|
* to go back to. `withTransaction` holds the single DB lock for the whole
|
||||||
* BEGIN..COMMIT so the statements cannot be split across pooled connections
|
* BEGIN..COMMIT so the statements cannot be split across pooled connections
|
||||||
* (see `db.ts`); the BEGIN/COMMIT/ROLLBACK themselves are ours to issue.
|
* (see `db.ts`); the BEGIN/COMMIT/ROLLBACK themselves are ours to issue.
|
||||||
|
*
|
||||||
|
* A committed restore replaces the data every getting-started counter reads,
|
||||||
|
* so it is announced on the data channel: one point for the three restore
|
||||||
|
* modes, reached only after the COMMIT (a rollback rethrows) and once the DB
|
||||||
|
* lock is released (ADR 0022).
|
||||||
*/
|
*/
|
||||||
async function runRestore(
|
async function runRestore(
|
||||||
body: (db: Database) => Promise<void>
|
body: (db: Database) => Promise<void>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return withTransaction(async (db) => {
|
await withTransaction(async (db) => {
|
||||||
await db.execute("BEGIN");
|
await db.execute("BEGIN");
|
||||||
let open = true;
|
let open = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -436,6 +442,7 @@ async function runRestore(
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreCategories(
|
async function restoreCategories(
|
||||||
|
|
|
||||||
94
src/services/importSourceService.test.ts
Normal file
94
src/services/importSourceService.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./db", () => {
|
||||||
|
const getDb = vi.fn();
|
||||||
|
return {
|
||||||
|
getDb,
|
||||||
|
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { getDb } from "./db";
|
||||||
|
import { subscribeDataChanged } from "./dataEvents";
|
||||||
|
import {
|
||||||
|
createSource,
|
||||||
|
deleteSource,
|
||||||
|
updateSource,
|
||||||
|
type ImportSourceInput,
|
||||||
|
} from "./importSourceService";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockExecute = vi.fn();
|
||||||
|
const mockDb = { select: mockSelect, execute: mockExecute };
|
||||||
|
|
||||||
|
/** Number of `execute` calls already made at each data-change notification. */
|
||||||
|
const notified: number[] = [];
|
||||||
|
let unsubscribe: () => void = () => undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
|
mockSelect.mockReset();
|
||||||
|
mockExecute.mockReset();
|
||||||
|
notified.length = 0;
|
||||||
|
unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input: ImportSourceInput = {
|
||||||
|
name: "Desjardins",
|
||||||
|
date_format: "YYYY-MM-DD",
|
||||||
|
delimiter: ",",
|
||||||
|
encoding: "utf-8",
|
||||||
|
column_mapping: '{"date":0,"description":1,"amount":2}',
|
||||||
|
skip_lines: 0,
|
||||||
|
has_header: 1,
|
||||||
|
amount_mode: "single",
|
||||||
|
sign_convention: "negative_expense",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createSource — data-change notification (#396)", () => {
|
||||||
|
it("notifies once after the insert", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 4 });
|
||||||
|
await expect(createSource(input)).resolves.toBe(4);
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies after an upsert on an existing name, before the lookup that only reads", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 0 });
|
||||||
|
mockSelect.mockResolvedValueOnce([{ id: 9, name: "Desjardins" }]);
|
||||||
|
await expect(createSource(input)).resolves.toBe(9);
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when the write fails", async () => {
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("CHECK constraint failed"));
|
||||||
|
await expect(createSource(input)).rejects.toThrow("CHECK constraint failed");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteSource — data-change notification (#396)", () => {
|
||||||
|
it("notifies once after the delete", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await deleteSource(4);
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when the delete fails", async () => {
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("FOREIGN KEY constraint failed"));
|
||||||
|
await expect(deleteSource(4)).rejects.toThrow("FOREIGN KEY constraint failed");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateSource — no notification (#396)", () => {
|
||||||
|
it("changes no counter, so it stays silent", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await updateSource(4, { name: "Desjardins chèques" });
|
||||||
|
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import type { ImportFormatRow, ImportSource } from "../shared/types";
|
import type { ImportFormatRow, ImportSource } from "../shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -85,12 +86,19 @@ export async function createSource(
|
||||||
source.template_id ?? null,
|
source.template_id ?? null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
// The number of sources is a getting-started counter (ADR 0022). Announced
|
||||||
|
// right after the write: the lookup below only reads.
|
||||||
|
notifyDataChanged();
|
||||||
// On conflict, lastInsertId may be 0 — look up the existing row
|
// On conflict, lastInsertId may be 0 — look up the existing row
|
||||||
if (result.lastInsertId) return result.lastInsertId as number;
|
if (result.lastInsertId) return result.lastInsertId as number;
|
||||||
const existing = await getSourceByName(source.name);
|
const existing = await getSourceByName(source.name);
|
||||||
return existing!.id;
|
return existing!.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits a source in place. No data-change notification (ADR 0022): it changes
|
||||||
|
* no counter, the number of sources stays the same.
|
||||||
|
*/
|
||||||
export async function updateSource(
|
export async function updateSource(
|
||||||
id: number,
|
id: number,
|
||||||
source: Partial<ImportSourceInput>
|
source: Partial<ImportSourceInput>
|
||||||
|
|
@ -132,4 +140,5 @@ export async function updateSource(
|
||||||
export async function deleteSource(id: number): Promise<void> {
|
export async function deleteSource(id: number): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db.execute("DELETE FROM import_sources WHERE id = $1", [id]);
|
await db.execute("DELETE FROM import_sources WHERE id = $1", [id]);
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
src/services/importedFileService.test.ts
Normal file
142
src/services/importedFileService.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./db", () => {
|
||||||
|
const getDb = vi.fn();
|
||||||
|
return {
|
||||||
|
getDb,
|
||||||
|
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { getDb } from "./db";
|
||||||
|
import { subscribeDataChanged } from "./dataEvents";
|
||||||
|
import { TransactionLinkedToBalanceError } from "./transactionService";
|
||||||
|
import {
|
||||||
|
createImportedFile,
|
||||||
|
deleteAllImportsWithTransactions,
|
||||||
|
deleteImportWithTransactions,
|
||||||
|
updateFileStatus,
|
||||||
|
} from "./importedFileService";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockExecute = vi.fn();
|
||||||
|
const mockDb = { select: mockSelect, execute: mockExecute };
|
||||||
|
|
||||||
|
/** Number of `execute` calls already made at each data-change notification. */
|
||||||
|
const notified: number[] = [];
|
||||||
|
let unsubscribe: () => void = () => undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
|
mockSelect.mockReset();
|
||||||
|
mockExecute.mockReset();
|
||||||
|
notified.length = 0;
|
||||||
|
unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = {
|
||||||
|
source_id: 3,
|
||||||
|
filename: "releve-2026-09.csv",
|
||||||
|
file_hash: "abc",
|
||||||
|
row_count: 12,
|
||||||
|
status: "completed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkedRow = { transaction_id: 1, account_id: 2, account_name: "CELI", direction: "in" as const };
|
||||||
|
|
||||||
|
describe("createImportedFile — data-change notification (#396)", () => {
|
||||||
|
it("notifies once after inserting a new file record", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 21 });
|
||||||
|
await expect(createImportedFile(file)).resolves.toBe(21);
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies once after updating the record of a file imported again", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([{ id: 8 }]);
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await expect(createImportedFile(file)).resolves.toBe(8);
|
||||||
|
expect(String(mockExecute.mock.calls[0][0])).toContain("UPDATE imported_files");
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when the write fails", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(createImportedFile(file)).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateFileStatus — data-change notification (#396)", () => {
|
||||||
|
it("notifies once after the update", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await updateFileStatus(8, "error", 0, "insert failed");
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when the update fails", async () => {
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(updateFileStatus(8, "error")).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteImportWithTransactions — data-change notification (#396)", () => {
|
||||||
|
it("notifies once, after the last delete (orphaned source included)", async () => {
|
||||||
|
mockSelect
|
||||||
|
.mockResolvedValueOnce([{ source_id: 3 }]) // source of the file
|
||||||
|
.mockResolvedValueOnce([]) // no linked transfer
|
||||||
|
.mockResolvedValueOnce([{ cnt: 0 }]); // no file left for the source
|
||||||
|
mockExecute.mockResolvedValue({ rowsAffected: 12 });
|
||||||
|
await expect(deleteImportWithTransactions(8)).resolves.toBe(12);
|
||||||
|
expect(mockExecute).toHaveBeenCalledTimes(3);
|
||||||
|
expect(notified).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when the pre-flight refuses (transactions linked to the Balance sheet)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([{ source_id: 3 }]).mockResolvedValueOnce([linkedRow]);
|
||||||
|
await expect(deleteImportWithTransactions(8)).rejects.toBeInstanceOf(
|
||||||
|
TransactionLinkedToBalanceError,
|
||||||
|
);
|
||||||
|
expect(mockExecute).not.toHaveBeenCalled();
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when a delete fails", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([{ source_id: 3 }]).mockResolvedValueOnce([]);
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 12 })
|
||||||
|
.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(deleteImportWithTransactions(8)).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteAllImportsWithTransactions — data-change notification (#396)", () => {
|
||||||
|
it("notifies once, after the three deletes", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
mockExecute.mockResolvedValue({ rowsAffected: 40 });
|
||||||
|
await expect(deleteAllImportsWithTransactions()).resolves.toBe(40);
|
||||||
|
expect(notified).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when the pre-flight refuses", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([linkedRow]);
|
||||||
|
await expect(deleteAllImportsWithTransactions()).rejects.toBeInstanceOf(
|
||||||
|
TransactionLinkedToBalanceError,
|
||||||
|
);
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when a delete fails", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(deleteAllImportsWithTransactions()).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import { isLinkedTransactionFkError } from "./balance.service";
|
import { isLinkedTransactionFkError } from "./balance.service";
|
||||||
import { TransactionLinkedToBalanceError } from "./transactionService";
|
import { TransactionLinkedToBalanceError } from "./transactionService";
|
||||||
import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
|
import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
|
||||||
|
|
@ -38,11 +39,14 @@ export async function createImportedFile(file: {
|
||||||
"SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2",
|
"SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2",
|
||||||
[file.source_id, file.filename]
|
[file.source_id, file.filename]
|
||||||
);
|
);
|
||||||
|
// Both branches write the status that the "import a first file" step counts
|
||||||
|
// (`status = 'completed'`), so each one is announced (ADR 0022).
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
`UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`,
|
`UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`,
|
||||||
[file.file_hash, file.row_count, file.status, file.notes || null, existing[0].id]
|
[file.file_hash, file.row_count, file.status, file.notes || null, existing[0].id]
|
||||||
);
|
);
|
||||||
|
notifyDataChanged();
|
||||||
return existing[0].id;
|
return existing[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +62,7 @@ export async function createImportedFile(file: {
|
||||||
file.notes || null,
|
file.notes || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
notifyDataChanged();
|
||||||
return result.lastInsertId as number;
|
return result.lastInsertId as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,6 +77,7 @@ export async function updateFileStatus(
|
||||||
`UPDATE imported_files SET status = $1, row_count = COALESCE($2, row_count), notes = COALESCE($3, notes) WHERE id = $4`,
|
`UPDATE imported_files SET status = $1, row_count = COALESCE($2, row_count), notes = COALESCE($3, notes) WHERE id = $4`,
|
||||||
[status, rowCount ?? null, notes ?? null, id]
|
[status, rowCount ?? null, notes ?? null, id]
|
||||||
);
|
);
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllImportedFiles(): Promise<ImportedFileWithSource[]> {
|
export async function getAllImportedFiles(): Promise<ImportedFileWithSource[]> {
|
||||||
|
|
@ -142,6 +148,9 @@ export async function deleteImportWithTransactions(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announced once every delete went through; a refused or failed delete
|
||||||
|
// throws above and announces nothing (ADR 0022).
|
||||||
|
notifyDataChanged();
|
||||||
return result.rowsAffected;
|
return result.rowsAffected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,5 +184,6 @@ export async function deleteAllImportsWithTransactions(): Promise<number> {
|
||||||
}
|
}
|
||||||
await db.execute("DELETE FROM imported_files");
|
await db.execute("DELETE FROM imported_files");
|
||||||
await db.execute("DELETE FROM import_sources");
|
await db.execute("DELETE FROM import_sources");
|
||||||
|
notifyDataChanged();
|
||||||
return result.rowsAffected;
|
return result.rowsAffected;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,21 @@ vi.mock("./logService", () => ({
|
||||||
logError: vi.fn(),
|
logError: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getDb } from "./db";
|
import { getDb, withTransaction } from "./db";
|
||||||
import { logInfo } from "./logService";
|
import { logInfo } from "./logService";
|
||||||
|
import { subscribeDataChanged } from "./dataEvents";
|
||||||
import {
|
import {
|
||||||
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||||
getOnboardingCounters,
|
ONBOARDING_GUIDED_INITIALIZED_KEY,
|
||||||
|
getOnboardingSnapshot,
|
||||||
hideChecklist,
|
hideChecklist,
|
||||||
|
initializeGuidedPath,
|
||||||
|
resumeStep,
|
||||||
showChecklist,
|
showChecklist,
|
||||||
|
skipStep,
|
||||||
|
skippedStepKey,
|
||||||
} from "./onboardingService";
|
} from "./onboardingService";
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
|
||||||
const mockExecute = vi.fn();
|
|
||||||
const mockDb = { select: mockSelect, execute: mockExecute };
|
|
||||||
|
|
||||||
const countersRow = {
|
const countersRow = {
|
||||||
sources: 2,
|
sources: 2,
|
||||||
imports: 3,
|
imports: 3,
|
||||||
|
|
@ -35,31 +37,85 @@ const countersRow = {
|
||||||
transactions: 60,
|
transactions: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface FakeDbOptions {
|
||||||
|
prefs?: Record<string, string>;
|
||||||
|
/** Rows of the aggregated counters query; null = the driver returns no row. */
|
||||||
|
counters?: typeof countersRow | null;
|
||||||
|
/** Answer of `SELECT COUNT(*) AS transactions` (rule D9). */
|
||||||
|
transactions?: number;
|
||||||
|
/** Makes the matching `execute` reject, as SQLite would. */
|
||||||
|
failOn?: (sql: string, params: unknown[]) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes each SELECT by its SQL: the aggregated counters query, or a
|
* Just enough of `user_preferences` and of a transaction to watch what the
|
||||||
* `user_preferences` lookup answered from `prefs`.
|
* service writes: the preferences live in a map, BEGIN keeps a copy that
|
||||||
|
* ROLLBACK restores, and every `execute` is logged.
|
||||||
*/
|
*/
|
||||||
function wireSelect(prefs: Record<string, string>, row: unknown | null = countersRow) {
|
function makeFakeDb(options: FakeDbOptions = {}) {
|
||||||
mockSelect.mockImplementation(async (sql: string, params?: unknown[]) => {
|
let prefs: Record<string, string> = { ...options.prefs };
|
||||||
if (sql.includes("user_preferences")) {
|
let beforeTransaction: Record<string, string> | null = null;
|
||||||
|
const statements: string[] = [];
|
||||||
|
|
||||||
|
const select = vi.fn(async (sql: string, params?: unknown[]) => {
|
||||||
|
if (sql.includes("COUNT(*) AS transactions FROM transactions")) {
|
||||||
|
return [{ transactions: options.transactions ?? 0 }];
|
||||||
|
}
|
||||||
|
if (sql.includes("LIKE 'onboarding_skipped_%'")) {
|
||||||
|
return Object.entries(prefs)
|
||||||
|
.filter(([key]) => /^onboarding.skipped./.test(key))
|
||||||
|
.map(([key, value]) => ({ key, value }));
|
||||||
|
}
|
||||||
|
if (sql.includes("FROM user_preferences WHERE key = $1")) {
|
||||||
const key = String(params?.[0]);
|
const key = String(params?.[0]);
|
||||||
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
|
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
|
||||||
}
|
}
|
||||||
|
if (sql.includes("FROM import_sources")) {
|
||||||
|
const row = options.counters === undefined ? countersRow : options.counters;
|
||||||
return row === null ? [] : [row];
|
return row === null ? [] : [row];
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected SELECT: ${sql}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const execute = vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||||
|
statements.push(sql.trim().split(/\s+/).slice(0, 3).join(" "));
|
||||||
|
if (options.failOn?.(sql, params)) throw new Error("database is locked");
|
||||||
|
if (sql === "BEGIN") {
|
||||||
|
beforeTransaction = { ...prefs };
|
||||||
|
} else if (sql === "COMMIT") {
|
||||||
|
beforeTransaction = null;
|
||||||
|
} else if (sql === "ROLLBACK") {
|
||||||
|
if (beforeTransaction) prefs = beforeTransaction;
|
||||||
|
beforeTransaction = null;
|
||||||
|
} else if (sql.includes("INSERT INTO user_preferences")) {
|
||||||
|
prefs[String(params[0])] = String(params[1]);
|
||||||
|
} else if (sql.includes("DELETE FROM user_preferences")) {
|
||||||
|
delete prefs[String(params[0])];
|
||||||
|
} else {
|
||||||
|
throw new Error(`unexpected execute: ${sql}`);
|
||||||
|
}
|
||||||
|
return { rowsAffected: 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { db: { select, execute }, statements, prefs: () => prefs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFakeDb(options: FakeDbOptions = {}) {
|
||||||
|
const fake = makeFakeDb(options);
|
||||||
|
vi.mocked(getDb).mockResolvedValue(fake.db as never);
|
||||||
|
return fake;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
vi.mocked(getDb).mockReset();
|
||||||
mockSelect.mockReset();
|
vi.mocked(withTransaction).mockClear();
|
||||||
mockExecute.mockReset();
|
|
||||||
vi.mocked(logInfo).mockReset();
|
vi.mocked(logInfo).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getOnboardingCounters (#351)", () => {
|
describe("getOnboardingSnapshot (#351, #396)", () => {
|
||||||
it("maps the aggregated row to camelCase counters", async () => {
|
it("maps the aggregated row to camelCase counters", async () => {
|
||||||
wireSelect({});
|
useFakeDb();
|
||||||
const snapshot = await getOnboardingCounters();
|
const snapshot = await getOnboardingSnapshot();
|
||||||
expect(snapshot.counters).toEqual({
|
expect(snapshot.counters).toEqual({
|
||||||
sources: 2,
|
sources: 2,
|
||||||
imports: 3,
|
imports: 3,
|
||||||
|
|
@ -69,54 +125,67 @@ describe("getOnboardingCounters (#351)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the five sub-counts in one query plus the two preference reads", async () => {
|
it("runs the five sub-counts in one query, plus the two preference reads and the skipped steps", async () => {
|
||||||
wireSelect({});
|
const { db } = useFakeDb();
|
||||||
await getOnboardingCounters();
|
await getOnboardingSnapshot();
|
||||||
expect(mockSelect).toHaveBeenCalledTimes(3);
|
expect(db.select).toHaveBeenCalledTimes(4);
|
||||||
const sqls = mockSelect.mock.calls.map((c) => String(c[0]));
|
const sqls = db.select.mock.calls.map((c) => String(c[0]));
|
||||||
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
|
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
|
||||||
expect(aggregate).toContain("FROM imported_files WHERE status = 'completed'");
|
expect(aggregate).toContain("FROM imported_files WHERE status = 'completed'");
|
||||||
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
|
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
|
||||||
expect(aggregate).toContain("FROM budget_entries");
|
expect(aggregate).toContain("FROM budget_entries");
|
||||||
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
|
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
|
||||||
const prefKeys = mockSelect.mock.calls
|
const prefKeys = db.select.mock.calls
|
||||||
.filter((c) => String(c[0]).includes("user_preferences"))
|
.filter((c) => String(c[0]).includes("WHERE key = $1"))
|
||||||
.map((c) => (c[1] as unknown[])[0]);
|
.map((c) => (c[1] as unknown[])[0]);
|
||||||
expect(prefKeys).toEqual(
|
expect(prefKeys).toEqual(
|
||||||
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
|
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
|
||||||
);
|
);
|
||||||
|
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports importFolderSet=false and hidden=false without preferences", async () => {
|
it("is a pure read: it never writes, even on a profile D9 would hide", async () => {
|
||||||
wireSelect({});
|
const { db } = useFakeDb({ transactions: 60 });
|
||||||
const snapshot = await getOnboardingCounters();
|
const unsubscribe = subscribeDataChanged(() => {
|
||||||
|
throw new Error("a snapshot must not announce anything");
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await getOnboardingSnapshot();
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
expect(db.execute).not.toHaveBeenCalled();
|
||||||
|
expect(withTransaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports importFolderSet=false, hidden=false and no skipped step without preferences", async () => {
|
||||||
|
useFakeDb();
|
||||||
|
const snapshot = await getOnboardingSnapshot();
|
||||||
expect(snapshot.importFolderSet).toBe(false);
|
expect(snapshot.importFolderSet).toBe(false);
|
||||||
expect(snapshot.hidden).toBe(false);
|
expect(snapshot.hidden).toBe(false);
|
||||||
|
expect(snapshot.skipped).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports importFolderSet=true when a folder is stored", async () => {
|
it("reports importFolderSet=true when a folder is stored", async () => {
|
||||||
wireSelect({ import_folder: "/home/max/releves" });
|
useFakeDb({ prefs: { import_folder: "/home/max/releves" } });
|
||||||
const snapshot = await getOnboardingCounters();
|
expect((await getOnboardingSnapshot()).importFolderSet).toBe(true);
|
||||||
expect(snapshot.importFolderSet).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats an empty import_folder value as unset", async () => {
|
it("treats an empty import_folder value as unset", async () => {
|
||||||
wireSelect({ import_folder: "" });
|
useFakeDb({ prefs: { import_folder: "" } });
|
||||||
const snapshot = await getOnboardingCounters();
|
expect((await getOnboardingSnapshot()).importFolderSet).toBe(false);
|
||||||
expect(snapshot.importFolderSet).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports hidden=true only for the value "1"', async () => {
|
it('reports hidden=true only for the value "1"', async () => {
|
||||||
wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" });
|
useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
|
||||||
expect((await getOnboardingCounters()).hidden).toBe(true);
|
expect((await getOnboardingSnapshot()).hidden).toBe(true);
|
||||||
wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" });
|
useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" } });
|
||||||
expect((await getOnboardingCounters()).hidden).toBe(false);
|
expect((await getOnboardingSnapshot()).hidden).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to zero counters when the driver returns no row", async () => {
|
it("falls back to zero counters when the driver returns no row", async () => {
|
||||||
wireSelect({}, null);
|
useFakeDb({ counters: null });
|
||||||
const snapshot = await getOnboardingCounters();
|
expect((await getOnboardingSnapshot()).counters).toEqual({
|
||||||
expect(snapshot.counters).toEqual({
|
|
||||||
sources: 0,
|
sources: 0,
|
||||||
imports: 0,
|
imports: 0,
|
||||||
manualCategorized: 0,
|
manualCategorized: 0,
|
||||||
|
|
@ -124,32 +193,214 @@ describe("getOnboardingCounters (#351)", () => {
|
||||||
transactions: 0,
|
transactions: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads the skipped steps in checklist order", async () => {
|
||||||
|
useFakeDb({
|
||||||
|
prefs: {
|
||||||
|
[skippedStepKey("budget")]: "1",
|
||||||
|
[skippedStepKey("source")]: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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 () => {
|
||||||
|
useFakeDb({
|
||||||
|
prefs: {
|
||||||
|
onboarding_skipped_dragons: "1",
|
||||||
|
onboarding_skipped_: "1",
|
||||||
|
onboarding_skipped_import_extra: "1",
|
||||||
|
onboardingXskippedYimport: "1",
|
||||||
|
[skippedStepKey("import")]: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await getOnboardingSnapshot()).skipped).toEqual(["import"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a skipped step only for the value "1"', async () => {
|
||||||
|
useFakeDb({
|
||||||
|
prefs: {
|
||||||
|
[skippedStepKey("source")]: "0",
|
||||||
|
[skippedStepKey("import")]: "true",
|
||||||
|
[skippedStepKey("budget")]: "",
|
||||||
|
[skippedStepKey("manualCategory")]: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await getOnboardingSnapshot()).skipped).toEqual(["manualCategory"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initializeGuidedPath — rule D9 (#396)", () => {
|
||||||
|
it("without the flag and with transactions: hides, then flags, inside one transaction", async () => {
|
||||||
|
const fake = useFakeDb({ transactions: 3 });
|
||||||
|
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: true });
|
||||||
|
|
||||||
|
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||||
|
const writes = fake.db.execute.mock.calls.map(([sql, params]) => [
|
||||||
|
String(sql).trim().split(/\s+/)[0],
|
||||||
|
(params as unknown[] | undefined)?.[0],
|
||||||
|
]);
|
||||||
|
expect(writes).toEqual([
|
||||||
|
["BEGIN", undefined],
|
||||||
|
["INSERT", ONBOARDING_CHECKLIST_HIDDEN_KEY],
|
||||||
|
["INSERT", ONBOARDING_GUIDED_INITIALIZED_KEY],
|
||||||
|
["COMMIT", undefined],
|
||||||
|
]);
|
||||||
|
// The flag and the transaction count are read after BEGIN.
|
||||||
|
const firstSelect = fake.db.select.mock.invocationCallOrder[0];
|
||||||
|
expect(firstSelect).toBeGreaterThan(fake.db.execute.mock.invocationCallOrder[0]);
|
||||||
|
expect(fake.prefs()).toEqual({
|
||||||
|
[ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1",
|
||||||
|
[ONBOARDING_GUIDED_INITIALIZED_KEY]: "1",
|
||||||
|
});
|
||||||
|
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without the flag and without transactions: flags only, the path stays visible", async () => {
|
||||||
|
const fake = useFakeDb({ transactions: 0 });
|
||||||
|
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
||||||
|
expect(fake.statements).toEqual(["BEGIN", "INSERT INTO user_preferences", "COMMIT"]);
|
||||||
|
expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
|
||||||
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with the flag already set: no write at all, even with transactions", async () => {
|
||||||
|
const fake = useFakeDb({
|
||||||
|
transactions: 3,
|
||||||
|
prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" },
|
||||||
|
});
|
||||||
|
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
||||||
|
expect(fake.statements).toEqual(["BEGIN", "COMMIT"]);
|
||||||
|
expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
|
||||||
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a profile shown again after D9 is not hidden a second time", async () => {
|
||||||
|
// D9 hid it, the user showed it again from Settings → Users.
|
||||||
|
const fake = useFakeDb({ transactions: 3 });
|
||||||
|
await initializeGuidedPath();
|
||||||
|
await showChecklist();
|
||||||
|
await initializeGuidedPath();
|
||||||
|
expect(fake.prefs()[ONBOARDING_CHECKLIST_HIDDEN_KEY]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed hiding write rolls back and leaves the flag unset", async () => {
|
||||||
|
const fake = useFakeDb({
|
||||||
|
transactions: 3,
|
||||||
|
failOn: (sql, params) =>
|
||||||
|
sql.includes("INSERT INTO user_preferences") && params[0] === ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||||
|
});
|
||||||
|
await expect(initializeGuidedPath()).rejects.toThrow("database is locked");
|
||||||
|
expect(fake.statements).toEqual(["BEGIN", "INSERT INTO user_preferences", "ROLLBACK"]);
|
||||||
|
expect(fake.prefs()[ONBOARDING_GUIDED_INITIALIZED_KEY]).toBeUndefined();
|
||||||
|
expect(fake.prefs()[ONBOARDING_CHECKLIST_HIDDEN_KEY]).toBeUndefined();
|
||||||
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed flag write rolls the hiding back too", async () => {
|
||||||
|
const fake = useFakeDb({
|
||||||
|
transactions: 3,
|
||||||
|
failOn: (sql, params) =>
|
||||||
|
sql.includes("INSERT INTO user_preferences") && params[0] === ONBOARDING_GUIDED_INITIALIZED_KEY,
|
||||||
|
});
|
||||||
|
await expect(initializeGuidedPath()).rejects.toThrow("database is locked");
|
||||||
|
expect(fake.statements[fake.statements.length - 1]).toBe("ROLLBACK");
|
||||||
|
expect(fake.prefs()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the next snapshot comes back hidden", async () => {
|
||||||
|
useFakeDb({ transactions: 3, counters: { ...countersRow, transactions: 3 } });
|
||||||
|
expect((await getOnboardingSnapshot()).hidden).toBe(false);
|
||||||
|
await initializeGuidedPath();
|
||||||
|
expect((await getOnboardingSnapshot()).hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not announce anything on the data channel", async () => {
|
||||||
|
useFakeDb({ transactions: 3 });
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = subscribeDataChanged(listener);
|
||||||
|
try {
|
||||||
|
await initializeGuidedPath();
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("hideChecklist / showChecklist (#351)", () => {
|
describe("hideChecklist / showChecklist (#351)", () => {
|
||||||
it('hideChecklist writes "1" under onboarding_checklist_hidden and logs', async () => {
|
it('hideChecklist writes "1" under onboarding_checklist_hidden and logs', async () => {
|
||||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
const fake = useFakeDb();
|
||||||
await hideChecklist();
|
await hideChecklist();
|
||||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
expect(fake.db.execute).toHaveBeenCalledTimes(1);
|
||||||
const [sql, params] = mockExecute.mock.calls[0];
|
const [sql, params] = fake.db.execute.mock.calls[0];
|
||||||
expect(String(sql)).toContain("INSERT INTO user_preferences");
|
expect(String(sql)).toContain("INSERT INTO user_preferences");
|
||||||
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
|
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
|
||||||
expect(logInfo).toHaveBeenCalledTimes(1);
|
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("showChecklist deletes the preference and logs", async () => {
|
it("showChecklist deletes the preference and logs", async () => {
|
||||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
const fake = useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
|
||||||
await showChecklist();
|
await showChecklist();
|
||||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
expect(fake.db.execute).toHaveBeenCalledTimes(1);
|
||||||
const [sql, params] = mockExecute.mock.calls[0];
|
const [sql, params] = fake.db.execute.mock.calls[0];
|
||||||
expect(String(sql)).toContain("DELETE FROM user_preferences");
|
expect(String(sql)).toContain("DELETE FROM user_preferences");
|
||||||
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY]);
|
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY]);
|
||||||
expect(logInfo).toHaveBeenCalledTimes(1);
|
expect(logInfo).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not log when the write fails", async () => {
|
it("does not log when the write fails", async () => {
|
||||||
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
useFakeDb({ failOn: () => true });
|
||||||
await expect(hideChecklist()).rejects.toThrow("database is locked");
|
await expect(hideChecklist()).rejects.toThrow("database is locked");
|
||||||
expect(logInfo).not.toHaveBeenCalled();
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("skipStep / resumeStep (#396, D3)", () => {
|
||||||
|
it('skipStep writes "1" under onboarding_skipped_<key>, without reading first', async () => {
|
||||||
|
const fake = useFakeDb();
|
||||||
|
await skipStep("budget");
|
||||||
|
expect(fake.db.select).not.toHaveBeenCalled();
|
||||||
|
expect(fake.db.execute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fake.db.execute.mock.calls[0][1]).toEqual(["onboarding_skipped_budget", "1"]);
|
||||||
|
expect(fake.prefs()).toEqual({ onboarding_skipped_budget: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skipStep is idempotent", async () => {
|
||||||
|
const fake = useFakeDb();
|
||||||
|
await skipStep("source");
|
||||||
|
await skipStep("source");
|
||||||
|
expect(fake.prefs()).toEqual({ onboarding_skipped_source: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumeStep deletes the preference, and is idempotent", async () => {
|
||||||
|
const fake = useFakeDb({ prefs: { onboarding_skipped_source: "1", onboarding_skipped_budget: "1" } });
|
||||||
|
await resumeStep("source");
|
||||||
|
await resumeStep("source");
|
||||||
|
expect(fake.prefs()).toEqual({ onboarding_skipped_budget: "1" });
|
||||||
|
expect(String(fake.db.execute.mock.calls[0][0])).toContain("DELETE FROM user_preferences");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skip then resume round-trips through the snapshot", async () => {
|
||||||
|
useFakeDb();
|
||||||
|
await skipStep("manualCategory");
|
||||||
|
expect((await getOnboardingSnapshot()).skipped).toEqual(["manualCategory"]);
|
||||||
|
await resumeStep("manualCategory");
|
||||||
|
expect((await getOnboardingSnapshot()).skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neither announces on the data channel", async () => {
|
||||||
|
useFakeDb();
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = subscribeDataChanged(listener);
|
||||||
|
try {
|
||||||
|
await skipStep("budget");
|
||||||
|
await resumeStep("budget");
|
||||||
|
await hideChecklist();
|
||||||
|
await showChecklist();
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getDb } from "./db";
|
import { getDb, withTransaction } from "./db";
|
||||||
import {
|
import {
|
||||||
deletePreference,
|
deletePreference,
|
||||||
getImportFolder,
|
getImportFolder,
|
||||||
|
|
@ -6,26 +6,44 @@ import {
|
||||||
setPreference,
|
setPreference,
|
||||||
} from "./userPreferenceService";
|
} from "./userPreferenceService";
|
||||||
import { logInfo } from "./logService";
|
import { logInfo } from "./logService";
|
||||||
import type { OnboardingCounters } from "../shared/types";
|
import {
|
||||||
|
CHECKLIST_ITEM_KEYS,
|
||||||
|
type ChecklistItemKey,
|
||||||
|
type OnboardingCounters,
|
||||||
|
} from "../shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Getting-started checklist data (#351).
|
* Getting-started path data (#351, #396).
|
||||||
*
|
*
|
||||||
* Everything the checklist needs is read in a single round of parallel reads:
|
* `getOnboardingSnapshot` reads everything the path needs in one round of
|
||||||
* one aggregated query for the counters, the import folder preference and the
|
* parallel reads, and writes nothing: the counters, the import folder, the
|
||||||
* "hidden" preference. No UI lives here; `computeChecklist` turns the result
|
* "hidden" preference and the skipped steps. The writes are separate, and the
|
||||||
* into rows and `useOnboardingChecklist` holds the state.
|
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
|
||||||
|
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** `user_preferences` key set to "1" when the user dismissed the checklist. */
|
/** `user_preferences` key set to "1" when the path is hidden (D8: one state for the banner and the list). */
|
||||||
export const ONBOARDING_CHECKLIST_HIDDEN_KEY = "onboarding_checklist_hidden";
|
export const ONBOARDING_CHECKLIST_HIDDEN_KEY = "onboarding_checklist_hidden";
|
||||||
|
|
||||||
|
/** `user_preferences` key set to "1" once rule D9 ran for the profile. */
|
||||||
|
export const ONBOARDING_GUIDED_INITIALIZED_KEY = "onboarding_guided_initialized";
|
||||||
|
|
||||||
|
/** `user_preferences` key of a skipped step (D3): `onboarding_skipped_<key>`, value "1". */
|
||||||
|
export function skippedStepKey(key: ChecklistItemKey): string {
|
||||||
|
return `onboarding_skipped_${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OnboardingSnapshot {
|
export interface OnboardingSnapshot {
|
||||||
counters: OnboardingCounters;
|
counters: OnboardingCounters;
|
||||||
/** True when an import folder has been chosen. */
|
/** True when an import folder has been chosen. */
|
||||||
importFolderSet: boolean;
|
importFolderSet: boolean;
|
||||||
/** True when the checklist was dismissed by the user. */
|
/** True when the path is hidden (by the user, or by rule D9). */
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
|
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
|
||||||
|
skipped: ChecklistItemKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountersRow {
|
interface CountersRow {
|
||||||
|
|
@ -45,6 +63,15 @@ const COUNTERS_SQL = `
|
||||||
(SELECT COUNT(*) FROM transactions) AS transactions
|
(SELECT COUNT(*) FROM transactions) AS transactions
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// `_` is a LIKE wildcard: the pattern may match more than the prefix, and the
|
||||||
|
// exact-key filter of `readSkippedSteps` is what decides.
|
||||||
|
const SKIPPED_SQL =
|
||||||
|
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
|
||||||
|
|
||||||
|
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`;
|
||||||
|
|
||||||
async function readCounters(): Promise<OnboardingCounters> {
|
async function readCounters(): Promise<OnboardingCounters> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.select<CountersRow[]>(COUNTERS_SQL);
|
const rows = await db.select<CountersRow[]>(COUNTERS_SQL);
|
||||||
|
|
@ -58,28 +85,105 @@ async function readCounters(): Promise<OnboardingCounters> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the counters, the import folder and the hidden flag in parallel. */
|
/** Skipped steps, read in one query and kept only for a valid key holding "1". */
|
||||||
export async function getOnboardingCounters(): Promise<OnboardingSnapshot> {
|
async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
|
||||||
const [counters, importFolder, hiddenPref] = await Promise.all([
|
const db = await getDb();
|
||||||
|
const rows = await db.select<Array<{ key: string; value: string }>>(SKIPPED_SQL);
|
||||||
|
const marked = new Set(rows.filter((row) => row.value === "1").map((row) => row.key));
|
||||||
|
return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(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`).
|
||||||
|
*/
|
||||||
|
export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
|
||||||
|
const [counters, importFolder, hiddenPref, skipped] = await Promise.all([
|
||||||
readCounters(),
|
readCounters(),
|
||||||
getImportFolder(),
|
getImportFolder(),
|
||||||
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
|
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
|
||||||
|
readSkippedSteps(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
counters,
|
counters,
|
||||||
importFolderSet: importFolder !== null && importFolder !== "",
|
importFolderSet: importFolder !== null && importFolder !== "",
|
||||||
hidden: hiddenPref === "1",
|
hidden: hiddenPref === "1",
|
||||||
|
skipped,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dismisses the checklist until `showChecklist()` is called. */
|
/**
|
||||||
|
* Rule D9, once per profile: at the first opening under this version, a
|
||||||
|
* profile that already has transactions gets the path hidden (same preference
|
||||||
|
* as "Hide", so Settings → Users shows it again); an empty or new profile sees
|
||||||
|
* it. Runs as one transaction under the DB lock, flag re-read inside it, so two
|
||||||
|
* concurrent first loads cannot apply it twice (CWE-362): the hidden preference
|
||||||
|
* is written first, then the flag, then COMMIT; any failure rolls both back and
|
||||||
|
* leaves the flag unset, so the next opening tries again.
|
||||||
|
*
|
||||||
|
* Returns `{ hidden: true }` only when this call hid the path, which it also
|
||||||
|
* writes to the in-app log. Not announced on the data channel.
|
||||||
|
*/
|
||||||
|
export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
|
||||||
|
const hidden = await withTransaction(async (db) => {
|
||||||
|
await db.execute("BEGIN");
|
||||||
|
try {
|
||||||
|
const flag = await db.select<Array<{ value: string }>>(
|
||||||
|
"SELECT value FROM user_preferences WHERE key = $1",
|
||||||
|
[ONBOARDING_GUIDED_INITIALIZED_KEY],
|
||||||
|
);
|
||||||
|
if (flag[0]?.value === "1") {
|
||||||
|
await db.execute("COMMIT");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rows = await db.select<Array<{ transactions: number }>>(
|
||||||
|
"SELECT COUNT(*) AS transactions FROM transactions",
|
||||||
|
);
|
||||||
|
const hasTransactions = (rows[0]?.transactions ?? 0) > 0;
|
||||||
|
if (hasTransactions) {
|
||||||
|
await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
|
||||||
|
}
|
||||||
|
await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_GUIDED_INITIALIZED_KEY, "1"]);
|
||||||
|
await db.execute("COMMIT");
|
||||||
|
return hasTransactions;
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await db.execute("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// Keep the original error: it is the one that explains the failure.
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hidden) {
|
||||||
|
logInfo(
|
||||||
|
"Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { hidden };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides the path until `showChecklist()` is called. */
|
||||||
export async function hideChecklist(): Promise<void> {
|
export async function hideChecklist(): Promise<void> {
|
||||||
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
|
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
|
||||||
logInfo("Onboarding: checklist hidden");
|
logInfo("Onboarding: checklist hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows the checklist again (removes the dismissal preference). */
|
/** Shows the path again (removes the hidden preference). */
|
||||||
export async function showChecklist(): Promise<void> {
|
export async function showChecklist(): Promise<void> {
|
||||||
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
|
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
|
||||||
logInfo("Onboarding: checklist shown");
|
logInfo("Onboarding: checklist shown");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marks a step as skipped. Idempotent: an UPSERT, no read before the write. */
|
||||||
|
export async function skipStep(key: ChecklistItemKey): Promise<void> {
|
||||||
|
await setPreference(skippedStepKey(key), "1");
|
||||||
|
logInfo(`Onboarding: step ${key} skipped`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels a skip. Idempotent: deleting a missing preference changes nothing. */
|
||||||
|
export async function resumeStep(key: ChecklistItemKey): Promise<void> {
|
||||||
|
await deletePreference(skippedStepKey(key));
|
||||||
|
logInfo(`Onboarding: step ${key} resumed`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("./db", () => {
|
vi.mock("./db", () => {
|
||||||
const getDb = vi.fn();
|
const getDb = vi.fn();
|
||||||
|
|
@ -9,16 +9,32 @@ vi.mock("./db", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { getTransactionCount, updateTransactionCategory } from "./transactionService";
|
import { subscribeDataChanged } from "./dataEvents";
|
||||||
|
import {
|
||||||
|
getTransactionCount,
|
||||||
|
insertBatch,
|
||||||
|
updateTransactionCategory,
|
||||||
|
updateTransactionNotes,
|
||||||
|
} from "./transactionService";
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
const mockSelect = vi.fn();
|
||||||
const mockExecute = vi.fn();
|
const mockExecute = vi.fn();
|
||||||
const mockDb = { select: mockSelect, execute: mockExecute };
|
const mockDb = { select: mockSelect, execute: mockExecute };
|
||||||
|
|
||||||
|
/** Number of `execute` calls already made at each data-change notification. */
|
||||||
|
const notified: number[] = [];
|
||||||
|
let unsubscribe: () => void = () => undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
mockSelect.mockReset();
|
mockSelect.mockReset();
|
||||||
mockExecute.mockReset();
|
mockExecute.mockReset();
|
||||||
|
notified.length = 0;
|
||||||
|
unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getTransactionCount (#351)", () => {
|
describe("getTransactionCount (#351)", () => {
|
||||||
|
|
@ -60,3 +76,48 @@ describe("updateTransactionCategory (#390)", () => {
|
||||||
expect(mockExecute.mock.calls[0][1]).toEqual([null, 0, 5]);
|
expect(mockExecute.mock.calls[0][1]).toEqual([null, 0, 5]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("updateTransactionCategory — data-change notification (#396)", () => {
|
||||||
|
it("notifies once after the update", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await updateTransactionCategory(5, 12, true);
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when the update fails", async () => {
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(updateTransactionCategory(5, 12, true)).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a note changes no counter: updateTransactionNotes stays silent", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await updateTransactionNotes(5, "loyer");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("insertBatch — data-change notification (#396)", () => {
|
||||||
|
const row = (i: number) => ({
|
||||||
|
date: "2026-09-0" + i,
|
||||||
|
description: `Achat ${i}`,
|
||||||
|
amount: -i,
|
||||||
|
source_id: 1,
|
||||||
|
file_id: 2,
|
||||||
|
original_description: `Achat ${i}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies once, after the last insert", async () => {
|
||||||
|
mockExecute.mockResolvedValue({ rowsAffected: 1 });
|
||||||
|
await expect(insertBatch([row(1), row(2), row(3)])).resolves.toBe(3);
|
||||||
|
expect(notified).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when an insert fails", async () => {
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1 })
|
||||||
|
.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(insertBatch([row(1), row(2), row(3)])).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import { categorizeBatch } from "./categorizationService";
|
import { categorizeBatch } from "./categorizationService";
|
||||||
import {
|
import {
|
||||||
isLinkedTransactionFkError,
|
isLinkedTransactionFkError,
|
||||||
|
|
@ -136,6 +137,9 @@ export async function insertBatch(
|
||||||
onProgress(insertedCount);
|
onProgress(insertedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Once, after the last insert: without it, the last re-read of an import
|
||||||
|
// could run before its transactions exist (ADR 0022).
|
||||||
|
notifyDataChanged();
|
||||||
return insertedCount;
|
return insertedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,6 +310,8 @@ export async function updateTransactionCategory(
|
||||||
`UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
|
`UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
|
||||||
[categoryId, toSqlFlag(isManual), txId]
|
[categoryId, toSqlFlag(isManual), txId]
|
||||||
);
|
);
|
||||||
|
// The manual flag is the "categorize by hand" step (ADR 0022).
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTransactionNotes(
|
export async function updateTransactionNotes(
|
||||||
|
|
|
||||||
65
src/services/userPreferenceService.test.ts
Normal file
65
src/services/userPreferenceService.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./db", () => {
|
||||||
|
const getDb = vi.fn();
|
||||||
|
return {
|
||||||
|
getDb,
|
||||||
|
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { getDb } from "./db";
|
||||||
|
import { subscribeDataChanged } from "./dataEvents";
|
||||||
|
import {
|
||||||
|
deletePreference,
|
||||||
|
getImportFolder,
|
||||||
|
setImportFolder,
|
||||||
|
setPreference,
|
||||||
|
} from "./userPreferenceService";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockExecute = vi.fn();
|
||||||
|
const mockDb = { select: mockSelect, execute: mockExecute };
|
||||||
|
|
||||||
|
/** Number of `execute` calls already made at each data-change notification. */
|
||||||
|
const notified: number[] = [];
|
||||||
|
let unsubscribe: () => void = () => undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
|
mockSelect.mockReset();
|
||||||
|
mockExecute.mockReset();
|
||||||
|
notified.length = 0;
|
||||||
|
unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setImportFolder — data-change notification (#396)", () => {
|
||||||
|
it("writes the import_folder preference, then notifies once", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||||
|
await setImportFolder("/home/max/releves");
|
||||||
|
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockExecute.mock.calls[0][1]).toEqual(["import_folder", "/home/max/releves"]);
|
||||||
|
expect(notified).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when the write fails", async () => {
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await expect(setImportFolder("/home/max/releves")).rejects.toThrow("database is locked");
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generic preferences do not notify (#396)", () => {
|
||||||
|
it("setPreference, deletePreference and reads stay silent", async () => {
|
||||||
|
mockExecute.mockResolvedValue({ rowsAffected: 1 });
|
||||||
|
mockSelect.mockResolvedValue([]);
|
||||||
|
await setPreference("budget-grid-expanded", "[]");
|
||||||
|
await deletePreference("budget-grid-expanded");
|
||||||
|
await getImportFolder();
|
||||||
|
expect(notified).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { notifyDataChanged } from "./dataEvents";
|
||||||
import type { UserPreference } from "../shared/types";
|
import type { UserPreference } from "../shared/types";
|
||||||
|
|
||||||
export async function getPreference(key: string): Promise<string | null> {
|
export async function getPreference(key: string): Promise<string | null> {
|
||||||
|
|
@ -35,6 +36,11 @@ export async function getImportFolder(): Promise<string | null> {
|
||||||
return getPreference("import_folder");
|
return getPreference("import_folder");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the import folder. The getting-started path counts it as a step, so
|
||||||
|
* a successful write is announced on the data channel (ADR 0022).
|
||||||
|
*/
|
||||||
export async function setImportFolder(path: string): Promise<void> {
|
export async function setImportFolder(path: string): Promise<void> {
|
||||||
return setPreference("import_folder", path);
|
await setPreference("import_folder", path);
|
||||||
|
notifyDataChanged();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -974,11 +974,30 @@ export type ChecklistItemKey =
|
||||||
| "manualCategory"
|
| "manualCategory"
|
||||||
| "budget";
|
| "budget";
|
||||||
|
|
||||||
|
/** The six checklist keys, in checklist order (#396: the only valid skipped-step keys). */
|
||||||
|
export const CHECKLIST_ITEM_KEYS: readonly ChecklistItemKey[] = [
|
||||||
|
"profile",
|
||||||
|
"importFolder",
|
||||||
|
"source",
|
||||||
|
"import",
|
||||||
|
"manualCategory",
|
||||||
|
"budget",
|
||||||
|
];
|
||||||
|
|
||||||
/** One row of the getting-started checklist. `to` is the route the row links to. */
|
/** One row of the getting-started checklist. `to` is the route the row links to. */
|
||||||
export interface ChecklistItem {
|
export interface ChecklistItem {
|
||||||
key: ChecklistItemKey;
|
key: ChecklistItemKey;
|
||||||
|
/** The data says the step is done. */
|
||||||
done: boolean;
|
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. */
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load status of the getting-started path (#396). `error` only before a first
|
||||||
|
* successful load: a later failed re-read keeps `ready` and the last state.
|
||||||
|
*/
|
||||||
|
export type OnboardingStatus = "loading" | "ready" | "error";
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,59 @@ describe("computeChecklist — budget gating (#351)", () => {
|
||||||
expect(list.complete).toBe(true);
|
expect(list.complete).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("computeChecklist — skipped steps (#396, D3)", () => {
|
||||||
|
const fiveDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||||
|
|
||||||
|
it("no row is skipped when the option is omitted or empty", () => {
|
||||||
|
expect(computeChecklist(zero, open).items.every((i) => !i.skipped)).toBe(true);
|
||||||
|
expect(computeChecklist(zero, { ...open, skipped: [] }).items.every((i) => !i.skipped)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped row is marked skipped, not done, and counts in the counter", () => {
|
||||||
|
const list = computeChecklist(zero, { ...open, skipped: ["importFolder"] });
|
||||||
|
const row = list.items.find((i) => i.key === "importFolder")!;
|
||||||
|
expect(row.skipped).toBe(true);
|
||||||
|
expect(row.done).toBe(false);
|
||||||
|
expect(list.done).toBe(2); // profile + the skipped row
|
||||||
|
expect(list.total).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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 budget = list.items.find((i) => i.key === "budget")!;
|
||||||
|
expect(budget.locked).toBe(true);
|
||||||
|
expect(list.total).toBe(5);
|
||||||
|
expect(list.done).toBe(1);
|
||||||
|
expect(list.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a row both done and skipped counts once", () => {
|
||||||
|
const list = computeChecklist(fiveDone, {
|
||||||
|
...open,
|
||||||
|
importFolderSet: true,
|
||||||
|
skipped: ["source", "import", "budget"],
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skipping does not change the rows' links", () => {
|
||||||
|
const to = computeChecklist(zero, { ...open, skipped: ["manualCategory", "budget"] }).items.map(
|
||||||
|
(i) => i.to,
|
||||||
|
);
|
||||||
|
expect(to).toEqual(computeChecklist(zero, open).items.map((i) => i.to));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ChecklistItem, OnboardingCounters } from "../shared/types";
|
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure computation of the getting-started checklist (#351).
|
* Pure computation of the getting-started checklist (#351).
|
||||||
|
|
@ -7,6 +7,10 @@ import type { ChecklistItem, OnboardingCounters } from "../shared/types";
|
||||||
* is only reachable from an open profile). The `budget` row is locked when the
|
* 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
|
* edition does not include Budget; a locked row is excluded from `total` and
|
||||||
* from `complete`, and links to the Users settings (license card) instead.
|
* from `complete`, and links to the Users settings (license card) instead.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ChecklistOptions {
|
export interface ChecklistOptions {
|
||||||
|
|
@ -14,15 +18,17 @@ export interface ChecklistOptions {
|
||||||
importFolderSet: boolean;
|
importFolderSet: boolean;
|
||||||
/** The Budget feature is part of the current edition. */
|
/** The Budget feature is part of the current edition. */
|
||||||
budgetAllowed: boolean;
|
budgetAllowed: boolean;
|
||||||
|
/** Steps the user skipped (#396). None when omitted. */
|
||||||
|
skipped?: readonly ChecklistItemKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Checklist {
|
export interface Checklist {
|
||||||
items: ChecklistItem[];
|
items: ChecklistItem[];
|
||||||
/** Number of unlocked rows that are done. */
|
/** Number of unlocked rows that are done or skipped. */
|
||||||
done: number;
|
done: number;
|
||||||
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
||||||
total: number;
|
total: number;
|
||||||
/** True when every unlocked row is done. */
|
/** True when every unlocked row is done or skipped. */
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,29 +37,32 @@ export function computeChecklist(
|
||||||
options: ChecklistOptions
|
options: ChecklistOptions
|
||||||
): Checklist {
|
): Checklist {
|
||||||
const budgetLocked = !options.budgetAllowed;
|
const budgetLocked = !options.budgetAllowed;
|
||||||
|
const skipped = new Set(options.skipped ?? []);
|
||||||
|
const row = (
|
||||||
|
key: ChecklistItemKey,
|
||||||
|
done: boolean,
|
||||||
|
to: string,
|
||||||
|
locked = false,
|
||||||
|
): ChecklistItem => ({ key, done, skipped: skipped.has(key), locked, to });
|
||||||
|
|
||||||
const items: ChecklistItem[] = [
|
const items: ChecklistItem[] = [
|
||||||
// Always done: the checklist only exists inside an open profile. The link
|
// Always done: the checklist only exists inside an open profile. The link
|
||||||
// is never rendered for a done row (#352); it points at profile management.
|
// is never rendered for a done row (#352); it points at profile management.
|
||||||
{ key: "profile", done: true, locked: false, to: "/settings/users" },
|
row("profile", true, "/settings/users"),
|
||||||
{ key: "importFolder", done: options.importFolderSet, locked: false, to: "/import" },
|
row("importFolder", options.importFolderSet, "/import"),
|
||||||
{ key: "source", done: counters.sources >= 1, locked: false, to: "/import" },
|
row("source", counters.sources >= 1, "/import"),
|
||||||
{ key: "import", done: counters.imports >= 1, locked: false, to: "/import" },
|
row("import", counters.imports >= 1, "/import"),
|
||||||
{
|
row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
|
||||||
key: "manualCategory",
|
row(
|
||||||
done: counters.manualCategorized >= 1,
|
"budget",
|
||||||
locked: false,
|
counters.budgetEntries >= 1,
|
||||||
to: "/transactions",
|
budgetLocked ? "/settings/users" : "/budget",
|
||||||
},
|
budgetLocked,
|
||||||
{
|
),
|
||||||
key: "budget",
|
|
||||||
done: counters.budgetEntries >= 1,
|
|
||||||
locked: budgetLocked,
|
|
||||||
to: budgetLocked ? "/settings/users" : "/budget",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const unlocked = items.filter((item) => !item.locked);
|
const unlocked = items.filter((item) => !item.locked);
|
||||||
const done = unlocked.filter((item) => item.done).length;
|
const done = unlocked.filter((item) => item.done || item.skipped).length;
|
||||||
const total = unlocked.length;
|
const total = unlocked.length;
|
||||||
|
|
||||||
return { items, done, total, complete: done === total };
|
return { items, done, total, complete: done === total };
|
||||||
|
|
|
||||||
310
src/utils/onboardingPath.test.ts
Normal file
310
src/utils/onboardingPath.test.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeChecklist, type Checklist } from "./onboardingChecklist";
|
||||||
|
import {
|
||||||
|
CARD_PAGES,
|
||||||
|
bannerMode,
|
||||||
|
latchCardPage,
|
||||||
|
nextStep,
|
||||||
|
readOnboardingNavState,
|
||||||
|
stepsOfPage,
|
||||||
|
type BannerModeInput,
|
||||||
|
} from "./onboardingPath";
|
||||||
|
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
|
const zero: OnboardingCounters = {
|
||||||
|
sources: 0,
|
||||||
|
imports: 0,
|
||||||
|
manualCategorized: 0,
|
||||||
|
budgetEntries: 0,
|
||||||
|
transactions: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Steps 2 to 5 done (folder, source, import, manual category). */
|
||||||
|
const upToManual: OnboardingCounters = {
|
||||||
|
...zero,
|
||||||
|
sources: 1,
|
||||||
|
imports: 1,
|
||||||
|
manualCategorized: 1,
|
||||||
|
transactions: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const checklistOf = (
|
||||||
|
counters: OnboardingCounters,
|
||||||
|
options: { importFolderSet?: boolean; budgetAllowed?: boolean; skipped?: ChecklistItemKey[] } = {},
|
||||||
|
): Checklist =>
|
||||||
|
computeChecklist(counters, {
|
||||||
|
importFolderSet: options.importFolderSet ?? false,
|
||||||
|
budgetAllowed: options.budgetAllowed ?? true,
|
||||||
|
skipped: options.skipped,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A row of the given step, as `computeChecklist` builds it. */
|
||||||
|
const step = (key: ChecklistItemKey): ChecklistItem =>
|
||||||
|
checklistOf(zero).items.find((item) => item.key === key)!;
|
||||||
|
|
||||||
|
const ALL_CARD_PAGES: ReadonlySet<string> = new Set(["/import", "/transactions", "/budget"]);
|
||||||
|
|
||||||
|
describe("nextStep (#396)", () => {
|
||||||
|
it("is the folder step on a new profile", () => {
|
||||||
|
expect(nextStep(checklistOf(zero))?.key).toBe("importFolder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the done rows", () => {
|
||||||
|
expect(nextStep(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))?.key).toBe(
|
||||||
|
"import",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the skipped rows", () => {
|
||||||
|
expect(nextStep(checklistOf(zero, { skipped: ["importFolder", "source"] }))?.key).toBe("import");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a locked row: Free edition with every other step done → null", () => {
|
||||||
|
expect(
|
||||||
|
nextStep(checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false })),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null at the end", () => {
|
||||||
|
expect(
|
||||||
|
nextStep(checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true })),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
nextStep(checklistOf(upToManual, { importFolderSet: true, skipped: ["budget"] })),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links through ChecklistItem.to", () => {
|
||||||
|
expect(nextStep(checklistOf(upToManual, { importFolderSet: true }))).toMatchObject({
|
||||||
|
key: "budget",
|
||||||
|
to: "/budget",
|
||||||
|
});
|
||||||
|
expect(nextStep(checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true })))
|
||||||
|
.toMatchObject({ key: "manualCategory", to: "/transactions" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stepsOfPage (#396)", () => {
|
||||||
|
it("maps each guided page to its steps, in checklist order", () => {
|
||||||
|
expect(stepsOfPage("/import")).toEqual(["importFolder", "source", "import"]);
|
||||||
|
expect(stepsOfPage("/transactions")).toEqual(["manualCategory"]);
|
||||||
|
expect(stepsOfPage("/budget")).toEqual(["budget"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives no step to any other page, inherited object keys included", () => {
|
||||||
|
for (const page of ["/", "/settings/users", "/reports", "constructor", "__proto__", ""]) {
|
||||||
|
expect(stepsOfPage(page)).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every card page guides at least one step", () => {
|
||||||
|
for (const page of CARD_PAGES) expect(stepsOfPage(page).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("latchCardPage (#396)", () => {
|
||||||
|
const latch = (
|
||||||
|
previous: string | null,
|
||||||
|
pathname: string,
|
||||||
|
next: ChecklistItem | null,
|
||||||
|
hidden = false,
|
||||||
|
) => latchCardPage(previous, { pathname, nextStep: next, hidden, cardPages: ALL_CARD_PAGES });
|
||||||
|
|
||||||
|
it("is set on the next step's page when that page carries a card", () => {
|
||||||
|
expect(latch(null, "/import", step("importFolder"))).toBe("/import");
|
||||||
|
expect(latch(null, "/transactions", step("manualCategory"))).toBe("/transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not set on the next step's page when that page carries no card", () => {
|
||||||
|
expect(
|
||||||
|
latchCardPage(null, {
|
||||||
|
pathname: "/import",
|
||||||
|
nextStep: step("importFolder"),
|
||||||
|
hidden: false,
|
||||||
|
cardPages: new Set(["/budget"]),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows CARD_PAGES by default", () => {
|
||||||
|
for (const [page, key] of [
|
||||||
|
["/import", "importFolder"],
|
||||||
|
["/transactions", "manualCategory"],
|
||||||
|
["/budget", "budget"],
|
||||||
|
] as const) {
|
||||||
|
const latched = latchCardPage(null, { pathname: page, nextStep: step(key), hidden: false });
|
||||||
|
expect(latched).toBe(CARD_PAGES.has(page) ? page : null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not set on a page that is not the next step's", () => {
|
||||||
|
expect(latch(null, "/budget", step("importFolder"))).toBeNull();
|
||||||
|
expect(latch(null, "/", step("importFolder"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not set at the end of the path", () => {
|
||||||
|
expect(latch(null, "/import", null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is released when the path is hidden, even on its page", () => {
|
||||||
|
expect(latch("/import", "/import", step("importFolder"), true)).toBeNull();
|
||||||
|
expect(latch(null, "/import", step("importFolder"), true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds when the step gets done with the page open, the next step being elsewhere", () => {
|
||||||
|
expect(latch("/transactions", "/transactions", step("budget"))).toBe("/transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds when the last step gets done with the page open", () => {
|
||||||
|
expect(latch("/budget", "/budget", null)).toBe("/budget");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is released by navigation, then set again only on the next step's card page", () => {
|
||||||
|
expect(latch("/import", "/reports", step("manualCategory"))).toBeNull();
|
||||||
|
expect(latch("/import", "/budget", step("manualCategory"))).toBeNull();
|
||||||
|
expect(latch("/import", "/transactions", step("manualCategory"))).toBe("/transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after "Skip" (latch cleared), it is set again when the new next step is on the same page', () => {
|
||||||
|
// Skipping the folder step on /import: the source step is on /import too.
|
||||||
|
expect(latch(null, "/import", step("source"))).toBe("/import");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after "Skip" (latch cleared), it stays released when the new next step is on another page', () => {
|
||||||
|
// Skipping the import step on /import: the manual category is on /transactions.
|
||||||
|
expect(latch(null, "/import", step("manualCategory"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent", () => {
|
||||||
|
const cases: Array<[string | null, string, ChecklistItem | null, boolean]> = [
|
||||||
|
[null, "/import", step("importFolder"), false],
|
||||||
|
["/import", "/import", step("budget"), false],
|
||||||
|
["/import", "/transactions", step("manualCategory"), false],
|
||||||
|
["/import", "/reports", step("manualCategory"), false],
|
||||||
|
["/import", "/import", step("importFolder"), true],
|
||||||
|
[null, "/budget", null, false],
|
||||||
|
];
|
||||||
|
for (const [previous, pathname, next, hidden] of cases) {
|
||||||
|
const once = latch(previous, pathname, next, hidden);
|
||||||
|
expect(latch(once, pathname, next, hidden)).toBe(once);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bannerMode (#396)", () => {
|
||||||
|
const midway = checklistOf({ ...zero, sources: 1 }, { importFolderSet: true });
|
||||||
|
const complete = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
|
||||||
|
const base: BannerModeInput = {
|
||||||
|
status: "ready",
|
||||||
|
checklist: midway,
|
||||||
|
hidden: false,
|
||||||
|
pathname: "/reports",
|
||||||
|
isEmptyDashboard: false,
|
||||||
|
budgetReady: true,
|
||||||
|
cardPage: null,
|
||||||
|
};
|
||||||
|
const mode = (overrides: Partial<BannerModeInput>) => bannerMode({ ...base, ...overrides });
|
||||||
|
|
||||||
|
it("shows the banner on an ordinary page", () => {
|
||||||
|
expect(mode({})).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing during the first load, or after a first load that failed", () => {
|
||||||
|
expect(mode({ status: "loading", checklist: null })).toBe("none");
|
||||||
|
expect(mode({ status: "error", checklist: null })).toBe("none");
|
||||||
|
expect(mode({ status: "loading" })).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the banner after a failed re-read (the reducer keeps status ready and the last state)", () => {
|
||||||
|
expect(mode({ status: "ready", checklist: midway })).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing when the path is hidden, card page included", () => {
|
||||||
|
expect(mode({ hidden: true })).toBe("none");
|
||||||
|
expect(mode({ hidden: true, pathname: "/import", cardPage: "/import" })).toBe("none");
|
||||||
|
expect(mode({ hidden: true, checklist: complete })).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing on the empty dashboard, whose welcome card holds the list (D11)", () => {
|
||||||
|
expect(mode({ pathname: "/", isEmptyDashboard: true })).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the banner on a dashboard with transactions", () => {
|
||||||
|
expect(mode({ pathname: "/" })).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the card on the latched page", () => {
|
||||||
|
expect(mode({ pathname: "/import", cardPage: "/import" })).toBe("card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the banner on a page that carries no card", () => {
|
||||||
|
expect(mode({ pathname: "/import", cardPage: null })).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the banner while a latch from another page has not been released yet", () => {
|
||||||
|
expect(mode({ pathname: "/transactions", cardPage: "/import" })).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the card once the step is done with the page open, end of the path included", () => {
|
||||||
|
expect(mode({ pathname: "/budget", cardPage: "/budget", checklist: complete })).toBe("card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the end message when every step is done or skipped (D4)", () => {
|
||||||
|
expect(mode({ checklist: complete })).toBe("complete");
|
||||||
|
const skippedEnd = checklistOf(upToManual, { importFolderSet: true, skipped: ["budget"] });
|
||||||
|
expect(mode({ checklist: skippedEnd })).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
const failClosed = checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false });
|
||||||
|
expect(failClosed.complete).toBe(true);
|
||||||
|
expect(mode({ checklist: failClosed, budgetReady: false })).toBe("none");
|
||||||
|
expect(mode({ checklist: failClosed, budgetReady: true })).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the banner before the Budget entitlement is known when steps remain", () => {
|
||||||
|
expect(mode({ budgetReady: false })).toBe("banner");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readOnboardingNavState (#396)", () => {
|
||||||
|
const none = { focus: false, guided: false };
|
||||||
|
|
||||||
|
it("reads anything that is not an object as no request", () => {
|
||||||
|
expect(readOnboardingNavState(null)).toEqual(none);
|
||||||
|
expect(readOnboardingNavState(undefined)).toEqual(none);
|
||||||
|
expect(readOnboardingNavState("onboarding")).toEqual(none);
|
||||||
|
expect(readOnboardingNavState(42)).toEqual(none);
|
||||||
|
expect(readOnboardingNavState(true)).toEqual(none);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a partial or malformed object as no request", () => {
|
||||||
|
expect(readOnboardingNavState({})).toEqual(none);
|
||||||
|
expect(readOnboardingNavState([])).toEqual(none);
|
||||||
|
expect(readOnboardingNavState({ onboarding: null })).toEqual(none);
|
||||||
|
expect(readOnboardingNavState({ onboarding: "focus" })).toEqual(none);
|
||||||
|
expect(readOnboardingNavState({ onboarding: {} })).toEqual(none);
|
||||||
|
expect(readOnboardingNavState({ onboarding: { focus: "yes" } })).toEqual(none);
|
||||||
|
expect(readOnboardingNavState({ onboarding: { focus: 1, guided: "true" } })).toEqual(none);
|
||||||
|
expect(readOnboardingNavState({ focus: true, guided: true })).toEqual(none);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the valid requests", () => {
|
||||||
|
expect(readOnboardingNavState({ onboarding: { focus: true } })).toEqual({
|
||||||
|
focus: true,
|
||||||
|
guided: false,
|
||||||
|
});
|
||||||
|
expect(readOnboardingNavState({ onboarding: { guided: true } })).toEqual({
|
||||||
|
focus: false,
|
||||||
|
guided: true,
|
||||||
|
});
|
||||||
|
expect(readOnboardingNavState({ onboarding: { focus: true, guided: true } })).toEqual({
|
||||||
|
focus: true,
|
||||||
|
guided: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
readOnboardingNavState({ from: "/", onboarding: { focus: true, guided: false, extra: 1 } }),
|
||||||
|
).toEqual({ focus: true, guided: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
130
src/utils/onboardingPath.ts
Normal file
130
src/utils/onboardingPath.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import type { Checklist } from "./onboardingChecklist";
|
||||||
|
import type { ChecklistItem, ChecklistItemKey, OnboardingStatus } from "../shared/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure rules of the guided getting-started path (#396).
|
||||||
|
*
|
||||||
|
* The provider (`useOnboardingChecklist`) feeds them its state; the banner and
|
||||||
|
* the step cards of the next issues only read what they decide. Nothing here
|
||||||
|
* touches React, the router or the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next step: the first unlocked row neither done nor skipped, or null when
|
||||||
|
* there is none left. Its link is `ChecklistItem.to`.
|
||||||
|
*/
|
||||||
|
export function nextStep(checklist: Checklist): ChecklistItem | null {
|
||||||
|
return checklist.items.find((item) => !item.locked && !item.done && !item.skipped) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes whose page carries a step card. Empty in this issue: each card issue
|
||||||
|
* adds its page, and a page without a delivered card keeps the banner, so a
|
||||||
|
* partly merged stack never removes any guidance.
|
||||||
|
*/
|
||||||
|
export const CARD_PAGES: ReadonlySet<string> = new Set<string>();
|
||||||
|
|
||||||
|
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
||||||
|
"/import": ["importFolder", "source", "import"],
|
||||||
|
"/transactions": ["manualCategory"],
|
||||||
|
"/budget": ["budget"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The steps a page guides, in checklist order (none for any other page). */
|
||||||
|
export function stepsOfPage(pathname: string): readonly ChecklistItemKey[] {
|
||||||
|
return Object.prototype.hasOwnProperty.call(PAGE_STEPS, pathname) ? PAGE_STEPS[pathname] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardLatchInput {
|
||||||
|
pathname: string;
|
||||||
|
nextStep: ChecklistItem | null;
|
||||||
|
hidden: boolean;
|
||||||
|
/** Pages that carry a card. Defaults to `CARD_PAGES`; tests pass their own. */
|
||||||
|
cardPages?: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card latch: the page that carries a step card, or null.
|
||||||
|
*
|
||||||
|
* - It is set when `pathname` is the route of the next step and is in the
|
||||||
|
* card pages.
|
||||||
|
* - It is released when the route changes, when the path is hidden, and after
|
||||||
|
* "Skip" when the next step is no longer on this page: the skip action clears
|
||||||
|
* the latch (`previous` = null), and this function sets it again only if the
|
||||||
|
* new next step is on the same page.
|
||||||
|
* - A step that gets done does not release it: the card stays to say "Done."
|
||||||
|
* and offer the next step, even when that step lives on another page.
|
||||||
|
*
|
||||||
|
* Idempotent: applying it to its own result changes nothing, which lets the
|
||||||
|
* provider re-run it on every render.
|
||||||
|
*/
|
||||||
|
export function latchCardPage(previous: string | null, input: CardLatchInput): string | null {
|
||||||
|
const { pathname, nextStep: next, hidden } = input;
|
||||||
|
const cardPages = input.cardPages ?? CARD_PAGES;
|
||||||
|
if (hidden) return null;
|
||||||
|
if (previous !== null && previous === pathname) return previous;
|
||||||
|
if (next !== null && next.to === pathname && cardPages.has(pathname)) return pathname;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BannerMode = "none" | "banner" | "card" | "complete";
|
||||||
|
|
||||||
|
export interface BannerModeInput {
|
||||||
|
status: OnboardingStatus;
|
||||||
|
checklist: Checklist | null;
|
||||||
|
hidden: boolean;
|
||||||
|
pathname: string;
|
||||||
|
/** The dashboard of a profile without any transaction (D11). */
|
||||||
|
isEmptyDashboard: boolean;
|
||||||
|
/** False until the Budget entitlement is known. */
|
||||||
|
budgetReady: boolean;
|
||||||
|
/** The latched card page (`latchCardPage`). */
|
||||||
|
cardPage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the path shows on the current page:
|
||||||
|
* - `none` before the first successful load, when the path is hidden (D8, D9),
|
||||||
|
* and on the empty dashboard, whose welcome card already holds the list (D11);
|
||||||
|
* - `card` on the latched card page, even once every step is done (the card
|
||||||
|
* 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;
|
||||||
|
* - `banner` everywhere else. Its counter waits for `budgetReady` too.
|
||||||
|
*
|
||||||
|
* A failed re-read after a first success keeps status `ready` (reducer), so
|
||||||
|
* the last state stays on screen.
|
||||||
|
*/
|
||||||
|
export function bannerMode(input: BannerModeInput): BannerMode {
|
||||||
|
const { status, checklist, hidden, pathname, isEmptyDashboard, budgetReady, cardPage } = input;
|
||||||
|
if (status !== "ready" || checklist === null) return "none";
|
||||||
|
if (hidden) return "none";
|
||||||
|
if (isEmptyDashboard) return "none";
|
||||||
|
if (cardPage !== null && cardPage === pathname) return "card";
|
||||||
|
if (checklist.complete) return budgetReady ? "complete" : "none";
|
||||||
|
return "banner";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a page may read from `location.state` about the path. */
|
||||||
|
export interface OnboardingNavState {
|
||||||
|
/** Move the focus to the step card's title. */
|
||||||
|
focus: boolean;
|
||||||
|
/** Open the page in its guided entry (Transactions: uncategorized only). */
|
||||||
|
guided: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only way to read `location.state` (typed `any` by react-router) for the
|
||||||
|
* path (CWE-476): anything that is not `{ onboarding: { focus: true } }` or
|
||||||
|
* `{ onboarding: { guided: true } }` reads as false, whatever its shape.
|
||||||
|
*/
|
||||||
|
export function readOnboardingNavState(state: unknown): OnboardingNavState {
|
||||||
|
if (typeof state !== "object" || state === null) return { focus: false, guided: false };
|
||||||
|
const onboarding: unknown = (state as { onboarding?: unknown }).onboarding;
|
||||||
|
if (typeof onboarding !== "object" || onboarding === null) {
|
||||||
|
return { focus: false, guided: false };
|
||||||
|
}
|
||||||
|
const flags = onboarding as { focus?: unknown; guided?: unknown };
|
||||||
|
return { focus: flags.focus === true, guided: flags.guided === true };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue