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

Merged
maximus merged 4 commits from issue-396-onboarding-shared-state into main 2026-10-09 01:10:05 +00:00
37 changed files with 3478 additions and 225 deletions

View file

@ -2,6 +2,10 @@
## [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é
- 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).

View file

@ -2,6 +2,10 @@
## [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
- 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).

View 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.

View file

@ -39,10 +39,10 @@ simpl-resultat/
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState)
│ │ └── 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)
│ ├── 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)
│ ├── 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
@ -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`).
## Services TypeScript (19)
## Services TypeScript (20)
| 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) |
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `onboardingService.ts` | 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) |
| `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_*) |
@ -226,7 +227,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements |
| `useBudget` | Budget |
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
| `useOnboardingChecklist` | 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) |
| `useHighlights` | Panneau de faits saillants du hub rapports |
| `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 |
| [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 |
| [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 |

View file

@ -1,12 +1,16 @@
import { Outlet } from "react-router-dom";
import Sidebar from "./Sidebar";
import { OnboardingProvider } from "../../contexts/OnboardingContext";
export default function AppShell() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto p-6">
{/* The getting-started path, shared by every page (#396). */}
<OnboardingProvider>
<Outlet />
</OnboardingProvider>
</main>
</div>
);

View 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|");
});
});

View 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;
}

View 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();
});
});

View file

@ -33,6 +33,7 @@ import {
findDuplicates,
} from "../services/transactionService";
import { categorizeBatch } from "../services/categorizationService";
import { notifyDataChanged } from "../services/dataEvents";
import {
getAllTemplates,
createTemplate,
@ -1156,6 +1157,11 @@ export function useImportWizard() {
payload: e instanceof Error ? e.message : String(e),
});
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,

View file

@ -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):
// 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
// useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both
@ -20,15 +20,24 @@ vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
import {
onboardingChecklistReducer,
initialOnboardingChecklistState,
createOnboardingLoader,
deriveOnboardingView,
followDataChanges,
DATA_CHANGE_DELAY_MS,
type OnboardingChecklistAction,
type OnboardingChecklistState,
type OnboardingLoaderDeps,
} from "./useOnboardingChecklist";
import { notifyDataChanged } from "../services/dataEvents";
import { computeChecklist } from "../utils/onboardingChecklist";
import type { OnboardingSnapshot } from "../services/onboardingService";
import type { OnboardingCounters } from "../shared/types";
const snapshot: OnboardingSnapshot = {
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 },
importFolderSet: true,
hidden: false,
skipped: [],
};
const reduce = (
@ -36,87 +45,688 @@ const reduce = (
from: OnboardingChecklistState = initialOnboardingChecklistState,
): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from);
describe("onboardingChecklistReducer (#351)", () => {
it("starts loading with no counters and not hidden", () => {
/** A first successful load (seq 1). */
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({
status: "loading",
counters: null,
importFolderSet: false,
hidden: false,
error: null,
skipped: [],
loadSeq: 0,
hiddenSetAt: 0,
skippedSetAt: 0,
cardPage: null,
});
});
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.counters).toEqual(snapshot.counters);
expect(s.importFolderSet).toBe(true);
expect(s.hidden).toBe(false);
expect(s.error).toBeNull();
expect(s.skipped).toEqual(["budget"]);
});
it("LOAD_DONE applies the persisted hidden flag", () => {
const s = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
expect(s.hidden).toBe(true);
expect(ready({ ...snapshot, hidden: true }).hidden).toBe(true);
});
it("LOAD_ERROR sets status=error with the message and keeps hidden", () => {
const hiddenReady = reduce([{ type: "LOAD_DONE", snapshot: { ...snapshot, hidden: true } }]);
it("a stale LOAD_DONE (an older load answering after a newer one started) is ignored", () => {
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(
[{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "database is locked" }],
[
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_ERROR", seq: 2 },
],
hiddenReady,
);
expect(s.status).toBe("error");
expect(s.error).toBe("database is locked");
expect(s.hidden).toBe(true);
// The last known counters survive a failed refresh.
expect(s.status).toBe("ready");
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", () => {
const s = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]);
it("a first load that fails sets status=error and keeps hidden=false", () => {
const s = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_ERROR", seq: 1 },
]);
expect(s.status).toBe("error");
expect(s.hidden).toBe(false);
expect(s.counters).toBeNull();
});
it("refresh after an error goes back to loading, then ready", () => {
const errored = reduce([{ type: "LOAD_START" }, { type: "LOAD_ERROR", error: "boom" }]);
const loading = reduce([{ type: "LOAD_START" }], errored);
expect(loading.status).toBe("loading");
expect(loading.error).toBeNull();
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" }]);
it("a stale LOAD_ERROR is ignored", () => {
const s = reduce([
{ type: "LOAD_START", seq: 1 },
{ type: "LOAD_START", seq: 2 },
{ type: "LOAD_ERROR", seq: 1 },
]);
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", () => {
const before = reduce([{ type: "LOAD_DONE", snapshot }]);
const before = ready();
const frozen = Object.freeze({ ...before });
expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow();
expect(() => onboardingChecklistReducer(frozen, { type: "SKIP", key: "budget" })).not.toThrow();
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();
});
});

View file

@ -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 {
getOnboardingCounters,
getOnboardingSnapshot,
hideChecklist,
initializeGuidedPath,
resumeStep,
showChecklist,
skipStep,
type OnboardingSnapshot,
} from "../services/onboardingService";
import { subscribeDataChanged } from "../services/dataEvents";
import { logError } from "../services/logService";
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
import {
bannerMode,
latchCardPage,
nextStep as findNextStep,
type BannerMode,
} from "../utils/onboardingPath";
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 are exported so the state machine is tested without renderHook (same
// pattern as `licenseReducer`, `feedbackReducer`, `migrationReducer`).
export type OnboardingChecklistStatus = "loading" | "ready" | "error";
// State of the getting-started path (#351, #396). One instance lives in
// `OnboardingProvider` (AppShell) and is shared by every page. The reducer, the
// loader and the data-channel follower are exported so the state machine is
// tested without renderHook (same pattern as `licenseReducer`,
// `performSubmitKey`, `revocationCheckLauncher`).
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. */
counters: OnboardingCounters | null;
/** True when an import folder has been chosen (read with the counters). */
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;
/** Message of the last load error, null otherwise. */
error: string | null;
/** Skipped steps, in checklist order. */
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 =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; snapshot: OnboardingSnapshot }
| { type: "LOAD_ERROR"; error: string }
| { type: "LOAD_START"; seq: number }
| { type: "LOAD_DONE"; seq: number; snapshot: OnboardingSnapshot }
| { type: "LOAD_ERROR"; seq: number }
| { 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 = {
status: "loading",
counters: null,
importFolderSet: 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(
state: OnboardingChecklistState,
action: OnboardingChecklistAction,
): OnboardingChecklistState {
switch (action.type) {
case "LOAD_START":
// The previous counters stay so a refresh does not blank the list.
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
// A re-read keeps what is on screen, `ready` included: only a path that
// never loaded shows `loading`.
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 {
...state,
status: "ready",
counters: action.snapshot.counters,
importFolderSet: action.snapshot.importFolderSet,
hidden: action.snapshot.hidden,
error: null,
counters: snapshot.counters,
importFolderSet: snapshot.importFolderSet,
// A local action dispatched after this load started wins over its
// 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":
// `hidden` is deliberately kept: a failed read must not resurface a
// checklist the user dismissed.
return { ...state, status: "error", error: action.error };
if (action.seq !== state.loadSeq) return state;
// After a first success the last state stays on screen; `hidden` is never
// touched, so a failed read cannot resurface a path the user dismissed.
return { ...state, status: state.status === "ready" ? "ready" : "error" };
case "HIDE":
return { ...state, hidden: true };
return { ...state, hidden: true, hiddenSetAt: state.loadSeq };
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);
}
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
* database, dismissal preference, and the Budget entitlement that decides
* whether the sixth row is locked.
* The provider's load function (#396), kept outside React so it is tested
* without a renderer:
* - 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(
onboardingChecklistReducer,
initialOnboardingChecklistState,
);
const { allowed: budgetAllowed, ready: budgetReady } = useEntitlement("budget");
const { pathname } = useLocation();
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
const snapshot = await getOnboardingCounters();
dispatch({ type: "LOAD_DONE", snapshot });
} catch (e) {
const error = errorMessage(e);
logError("Onboarding: failed to load checklist counters", error);
dispatch({ type: "LOAD_ERROR", error });
}
}, []);
// One loader per provider instance: a profile switch remounts AppShell, so
// the new profile gets its own numbering and its own D9 initialization.
const [refresh] = useState(() =>
createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
);
// Read-only local SELECTs: a StrictMode double run is harmless.
// Read at mount, then again at every route change.
useEffect(() => {
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 () => {
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(
() =>
state.counters
? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet,
budgetAllowed,
skipped: state.skipped,
})
: 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,
checklist,
/** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */
counters: state.counters,
hidden: state.hidden,
/** Message of the last load error, null otherwise. */
error: state.error,
budgetReady,
refresh,
nextStep,
mode,
cardPage,
hide,
show,
};
skip,
resume,
refresh,
}),
[
state.status,
checklist,
state.counters,
state.hidden,
budgetReady,
nextStep,
mode,
cardPage,
hide,
show,
skip,
resume,
refresh,
],
);
}

View 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);
});
});

View 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"');
});
});

View file

@ -14,13 +14,15 @@
// profile without any transaction gets `DashboardEmptyState` instead of the
// widgets, outside the `isLoading` veil so its link stays clickable. With
// 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
// v0.15.0 page without the list.
import { useState, useCallback, useMemo, useEffect } from "react";
// panel until it is complete or dismissed. A first load that fails falls back
// to the v0.15.0 page without the list; the provider logs it.
//
// 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 { useDashboard } from "../hooks/useDashboard";
import { useOnboardingChecklist } from "../hooks/useOnboardingChecklist";
import { logWarn } from "../services/logService";
import { useOnboarding } from "../contexts/OnboardingContext";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryBarChart from "../components/reports/CategoryBarChart";
@ -48,13 +50,7 @@ export default function DashboardPage() {
setAccountIds,
} = useDashboard();
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
const onboarding = useOnboardingChecklist();
useEffect(() => {
if (onboarding.status === "error") {
logWarn("Dashboard: getting-started checklist unavailable, rendering without it", onboarding.error);
}
}, [onboarding.status, onboarding.error]);
const onboarding = useOnboarding();
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);

View file

@ -1,5 +1,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getBudgetVsActualData,
getActualTotalsForYear,
upsertBudgetEntry,
deleteBudgetEntry,
upsertBudgetEntriesForYear,
applyTemplate,
saveAsTemplate,
} from "./budgetService";
import { subscribeDataChanged } from "./dataEvents";
vi.mock("./db", () => {
const getDb = vi.fn();
@ -9,11 +18,13 @@ vi.mock("./db", () => {
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
mockExecute.mockReset();
});
// 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]);
});
});
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([]);
});
});

View file

@ -1,4 +1,6 @@
import type Database from "@tauri-apps/plugin-sql";
import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import { inPlaceholders } from "../utils/sqlFilters";
import type {
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(
categoryId: number,
year: number,
@ -48,12 +70,8 @@ export async function upsertBudgetEntry(
notes?: string
): Promise<void> {
const db = await getDb();
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]
);
await writeBudgetEntry(db, categoryId, year, month, amount, notes);
notifyDataChanged();
}
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",
[categoryId, year, month]
);
notifyDataChanged();
}
export async function getActualsByCategory(
@ -116,6 +135,7 @@ export async function upsertBudgetEntriesForYear(
);
}
}
notifyDataChanged();
}
// Templates
@ -165,9 +185,12 @@ export async function applyTemplate(
month: number
): Promise<void> {
const entries = await getTemplateEntries(templateId);
const db = await getDb();
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> {

View file

@ -1,10 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
validateKeyword,
previewKeywordMatches,
applyKeywordWithReassignment,
KEYWORD_MAX_LENGTH,
} from "./categorizationService";
import { subscribeDataChanged } from "./dataEvents";
vi.mock("./db", () => {
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 mockExecute = vi.fn();
@ -180,3 +181,63 @@ describe("applyKeywordWithReassignment", () => {
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([]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb, withTransaction } from "./db";
import { notifyDataChanged } from "./dataEvents";
import type Database from "@tauri-apps/plugin-sql";
import type { Keyword, RecentTransaction } from "../shared/types";
@ -223,9 +224,14 @@ export async function applyKeywordWithReassignment(
}
const keyword = validation.value;
return withTransaction((db) =>
const result = await withTransaction((db) =>
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(

View 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();
});
});

View 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);
};
}

View file

@ -21,7 +21,7 @@
* transaction exists to survive.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("./db", () => {
const getDb = vi.fn();
@ -49,6 +49,7 @@ import {
type ExportImportSource,
type ExportImportTemplate,
} from "./dataExportService";
import { subscribeDataChanged } from "./dataEvents";
import { formatFromRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat";
import type { Category } from "../shared/types";
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]);
});
});
// ---------------------------------------------------------------------------
// 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([]);
});
});

View file

@ -1,5 +1,6 @@
import type Database from "@tauri-apps/plugin-sql";
import { getDb, withTransaction } from "./db";
import { notifyDataChanged } from "./dataEvents";
import Papa from "papaparse";
import { toSqlFlag } from "../utils/sqlFlag";
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
* BEGIN..COMMIT so the statements cannot be split across pooled connections
* (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(
body: (db: Database) => Promise<void>
): Promise<void> {
return withTransaction(async (db) => {
await withTransaction(async (db) => {
await db.execute("BEGIN");
let open = true;
try {
@ -436,6 +442,7 @@ async function runRestore(
throw e;
}
});
notifyDataChanged();
}
async function restoreCategories(

View 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([]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import type { ImportFormatRow, ImportSource } from "../shared/types";
/**
@ -85,12 +86,19 @@ export async function createSource(
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
if (result.lastInsertId) return result.lastInsertId as number;
const existing = await getSourceByName(source.name);
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(
id: number,
source: Partial<ImportSourceInput>
@ -132,4 +140,5 @@ export async function updateSource(
export async function deleteSource(id: number): Promise<void> {
const db = await getDb();
await db.execute("DELETE FROM import_sources WHERE id = $1", [id]);
notifyDataChanged();
}

View 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([]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import { isLinkedTransactionFkError } from "./balance.service";
import { TransactionLinkedToBalanceError } from "./transactionService";
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",
[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) {
await db.execute(
`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]
);
notifyDataChanged();
return existing[0].id;
}
@ -58,6 +62,7 @@ export async function createImportedFile(file: {
file.notes || null,
]
);
notifyDataChanged();
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`,
[status, rowCount ?? null, notes ?? null, id]
);
notifyDataChanged();
}
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;
}
@ -175,5 +184,6 @@ export async function deleteAllImportsWithTransactions(): Promise<number> {
}
await db.execute("DELETE FROM imported_files");
await db.execute("DELETE FROM import_sources");
notifyDataChanged();
return result.rowsAffected;
}

View file

@ -14,19 +14,21 @@ vi.mock("./logService", () => ({
logError: vi.fn(),
}));
import { getDb } from "./db";
import { getDb, withTransaction } from "./db";
import { logInfo } from "./logService";
import { subscribeDataChanged } from "./dataEvents";
import {
ONBOARDING_CHECKLIST_HIDDEN_KEY,
getOnboardingCounters,
ONBOARDING_GUIDED_INITIALIZED_KEY,
getOnboardingSnapshot,
hideChecklist,
initializeGuidedPath,
resumeStep,
showChecklist,
skipStep,
skippedStepKey,
} from "./onboardingService";
const mockSelect = vi.fn();
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };
const countersRow = {
sources: 2,
imports: 3,
@ -35,31 +37,85 @@ const countersRow = {
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
* `user_preferences` lookup answered from `prefs`.
* Just enough of `user_preferences` and of a transaction to watch what the
* 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) {
mockSelect.mockImplementation(async (sql: string, params?: unknown[]) => {
if (sql.includes("user_preferences")) {
function makeFakeDb(options: FakeDbOptions = {}) {
let prefs: Record<string, string> = { ...options.prefs };
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]);
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];
}
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(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
mockExecute.mockReset();
vi.mocked(getDb).mockReset();
vi.mocked(withTransaction).mockClear();
vi.mocked(logInfo).mockReset();
});
describe("getOnboardingCounters (#351)", () => {
describe("getOnboardingSnapshot (#351, #396)", () => {
it("maps the aggregated row to camelCase counters", async () => {
wireSelect({});
const snapshot = await getOnboardingCounters();
useFakeDb();
const snapshot = await getOnboardingSnapshot();
expect(snapshot.counters).toEqual({
sources: 2,
imports: 3,
@ -69,54 +125,67 @@ describe("getOnboardingCounters (#351)", () => {
});
});
it("runs the five sub-counts in one query plus the two preference reads", async () => {
wireSelect({});
await getOnboardingCounters();
expect(mockSelect).toHaveBeenCalledTimes(3);
const sqls = mockSelect.mock.calls.map((c) => String(c[0]));
it("runs the five sub-counts in one query, plus the two preference reads and the skipped steps", async () => {
const { db } = useFakeDb();
await getOnboardingSnapshot();
expect(db.select).toHaveBeenCalledTimes(4);
const sqls = db.select.mock.calls.map((c) => String(c[0]));
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
expect(aggregate).toContain("FROM imported_files WHERE status = 'completed'");
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
expect(aggregate).toContain("FROM budget_entries");
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
const prefKeys = mockSelect.mock.calls
.filter((c) => String(c[0]).includes("user_preferences"))
const prefKeys = db.select.mock.calls
.filter((c) => String(c[0]).includes("WHERE key = $1"))
.map((c) => (c[1] as unknown[])[0]);
expect(prefKeys).toEqual(
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
);
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
});
it("reports importFolderSet=false and hidden=false without preferences", async () => {
wireSelect({});
const snapshot = await getOnboardingCounters();
it("is a pure read: it never writes, even on a profile D9 would hide", async () => {
const { db } = useFakeDb({ transactions: 60 });
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.hidden).toBe(false);
expect(snapshot.skipped).toEqual([]);
});
it("reports importFolderSet=true when a folder is stored", async () => {
wireSelect({ import_folder: "/home/max/releves" });
const snapshot = await getOnboardingCounters();
expect(snapshot.importFolderSet).toBe(true);
useFakeDb({ prefs: { import_folder: "/home/max/releves" } });
expect((await getOnboardingSnapshot()).importFolderSet).toBe(true);
});
it("treats an empty import_folder value as unset", async () => {
wireSelect({ import_folder: "" });
const snapshot = await getOnboardingCounters();
expect(snapshot.importFolderSet).toBe(false);
useFakeDb({ prefs: { import_folder: "" } });
expect((await getOnboardingSnapshot()).importFolderSet).toBe(false);
});
it('reports hidden=true only for the value "1"', async () => {
wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" });
expect((await getOnboardingCounters()).hidden).toBe(true);
wireSelect({ [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" });
expect((await getOnboardingCounters()).hidden).toBe(false);
useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
expect((await getOnboardingSnapshot()).hidden).toBe(true);
useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "0" } });
expect((await getOnboardingSnapshot()).hidden).toBe(false);
});
it("falls back to zero counters when the driver returns no row", async () => {
wireSelect({}, null);
const snapshot = await getOnboardingCounters();
expect(snapshot.counters).toEqual({
useFakeDb({ counters: null });
expect((await getOnboardingSnapshot()).counters).toEqual({
sources: 0,
imports: 0,
manualCategorized: 0,
@ -124,32 +193,214 @@ describe("getOnboardingCounters (#351)", () => {
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)", () => {
it('hideChecklist writes "1" under onboarding_checklist_hidden and logs', async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
const fake = useFakeDb();
await hideChecklist();
expect(mockExecute).toHaveBeenCalledTimes(1);
const [sql, params] = mockExecute.mock.calls[0];
expect(fake.db.execute).toHaveBeenCalledTimes(1);
const [sql, params] = fake.db.execute.mock.calls[0];
expect(String(sql)).toContain("INSERT INTO user_preferences");
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
expect(logInfo).toHaveBeenCalledTimes(1);
});
it("showChecklist deletes the preference and logs", async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
const fake = useFakeDb({ prefs: { [ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1" } });
await showChecklist();
expect(mockExecute).toHaveBeenCalledTimes(1);
const [sql, params] = mockExecute.mock.calls[0];
expect(fake.db.execute).toHaveBeenCalledTimes(1);
const [sql, params] = fake.db.execute.mock.calls[0];
expect(String(sql)).toContain("DELETE FROM user_preferences");
expect(params).toEqual([ONBOARDING_CHECKLIST_HIDDEN_KEY]);
expect(logInfo).toHaveBeenCalledTimes(1);
});
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");
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();
});
});

View file

@ -1,4 +1,4 @@
import { getDb } from "./db";
import { getDb, withTransaction } from "./db";
import {
deletePreference,
getImportFolder,
@ -6,26 +6,44 @@ import {
setPreference,
} from "./userPreferenceService";
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:
* one aggregated query for the counters, the import folder preference and the
* "hidden" preference. No UI lives here; `computeChecklist` turns the result
* into rows and `useOnboardingChecklist` holds the state.
* `getOnboardingSnapshot` reads everything the path needs in one round of
* parallel reads, and writes nothing: the counters, the import folder, the
* "hidden" preference and the skipped steps. The writes are separate, and the
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is
* 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";
/** `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 {
counters: OnboardingCounters;
/** True when an import folder has been chosen. */
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;
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
skipped: ChecklistItemKey[];
}
interface CountersRow {
@ -45,6 +63,15 @@ const COUNTERS_SQL = `
(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> {
const db = await getDb();
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. */
export async function getOnboardingCounters(): Promise<OnboardingSnapshot> {
const [counters, importFolder, hiddenPref] = await Promise.all([
/** Skipped steps, read in one query and kept only for a valid key holding "1". */
async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
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(),
getImportFolder(),
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
readSkippedSteps(),
]);
return {
counters,
importFolderSet: importFolder !== null && importFolder !== "",
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> {
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
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> {
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
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`);
}

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("./db", () => {
const getDb = vi.fn();
@ -9,16 +9,32 @@ vi.mock("./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 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("getTransactionCount (#351)", () => {
@ -60,3 +76,48 @@ describe("updateTransactionCategory (#390)", () => {
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([]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import { categorizeBatch } from "./categorizationService";
import {
isLinkedTransactionFkError,
@ -136,6 +137,9 @@ export async function insertBatch(
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;
}
@ -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`,
[categoryId, toSqlFlag(isManual), txId]
);
// The manual flag is the "categorize by hand" step (ADR 0022).
notifyDataChanged();
}
export async function updateTransactionNotes(

View 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([]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import type { UserPreference } from "../shared/types";
export async function getPreference(key: string): Promise<string | null> {
@ -35,6 +36,11 @@ export async function getImportFolder(): Promise<string | null> {
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> {
return setPreference("import_folder", path);
await setPreference("import_folder", path);
notifyDataChanged();
}

View file

@ -974,11 +974,30 @@ export type ChecklistItemKey =
| "manualCategory"
| "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. */
export interface ChecklistItem {
key: ChecklistItemKey;
/** The data says the step is done. */
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. */
locked: boolean;
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";

View file

@ -130,3 +130,59 @@ describe("computeChecklist — budget gating (#351)", () => {
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));
});
});

View file

@ -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).
@ -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
* edition does not include Budget; a locked row is excluded from `total` and
* 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 {
@ -14,15 +18,17 @@ export interface ChecklistOptions {
importFolderSet: boolean;
/** The Budget feature is part of the current edition. */
budgetAllowed: boolean;
/** Steps the user skipped (#396). None when omitted. */
skipped?: readonly ChecklistItemKey[];
}
export interface Checklist {
items: ChecklistItem[];
/** Number of unlocked rows that are done. */
/** Number of unlocked rows that are done or skipped. */
done: number;
/** Number of unlocked rows (6, or 5 when Budget is locked). */
total: number;
/** True when every unlocked row is done. */
/** True when every unlocked row is done or skipped. */
complete: boolean;
}
@ -31,29 +37,32 @@ export function computeChecklist(
options: ChecklistOptions
): Checklist {
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[] = [
// 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.
{ key: "profile", done: true, locked: false, to: "/settings/users" },
{ key: "importFolder", done: options.importFolderSet, locked: false, to: "/import" },
{ key: "source", done: counters.sources >= 1, locked: false, to: "/import" },
{ key: "import", done: counters.imports >= 1, locked: false, to: "/import" },
{
key: "manualCategory",
done: counters.manualCategorized >= 1,
locked: false,
to: "/transactions",
},
{
key: "budget",
done: counters.budgetEntries >= 1,
locked: budgetLocked,
to: budgetLocked ? "/settings/users" : "/budget",
},
row("profile", true, "/settings/users"),
row("importFolder", options.importFolderSet, "/import"),
row("source", counters.sources >= 1, "/import"),
row("import", counters.imports >= 1, "/import"),
row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
row(
"budget",
counters.budgetEntries >= 1,
budgetLocked ? "/settings/users" : "/budget",
budgetLocked,
),
];
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;
return { items, done, total, complete: done === total };

View 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
View 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 };
}