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
4 changed files with 105 additions and 5 deletions
Showing only changes of commit 68b90f5f16 - Show all commits

View file

@ -2,6 +2,10 @@
## [Non publié] ## [Non publié]
### Modifié
- La liste de démarrage ne s'affiche plus dans un profil qui avait déjà des transactions avant cette version : à la première ouverture d'un tel profil, la liste est masquée, comme après un clic sur « Masquer », et « Afficher la liste de démarrage », dans Paramètres → Utilisateurs, la fait revenir. Un profil sans transaction la garde (#396).
### Corrigé ### Corrigé
- Classer une transaction à la main sur la page Transactions coche désormais l'étape « Classer une transaction à la main » de la liste de démarrage. L'application enregistrait cette marque sous une forme que la liste ne savait pas compter. Les transactions classées à la main avant cette version sont comptées dès l'ouverture du profil, et une sauvegarde exportée entre-temps les restaure correctement (#390). - Classer une transaction à la main sur la page Transactions coche désormais l'étape « Classer une transaction à la main » de la liste de démarrage. L'application enregistrait cette marque sous une forme que la liste ne savait pas compter. Les transactions classées à la main avant cette version sont comptées dès l'ouverture du profil, et une sauvegarde exportée entre-temps les restaure correctement (#390).

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Changed
- The getting-started checklist no longer shows in a profile that already had transactions before this version: the first time such a profile is opened, the checklist is hidden, as if you had clicked "Hide", and "Show the getting-started checklist" in Settings → Users brings it back. A profile without transactions keeps it (#396).
### Fixed ### Fixed
- Categorizing a transaction by hand on the Transactions page now ticks the "Categorize a transaction by hand" step of the getting-started checklist. The app stored that mark in a form the checklist could not count. Transactions you categorized by hand before this version are counted as soon as the profile is opened, and a backup exported in the meantime restores them correctly (#390). - Categorizing a transaction by hand on the Transactions page now ticks the "Categorize a transaction by hand" step of the getting-started checklist. The app stored that mark in a form the checklist could not count. Transactions you categorized by hand before this version are counted as soon as the profile is opened, and a backup exported in the meantime restores them correctly (#390).

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) │ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState) │ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState)
│ │ └── transactions/ # 5 composants │ │ └── transactions/ # 5 composants
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) │ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine) │ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 25 services métier (19 détaillés dans la section « Services TypeScript ») │ ├── services/ # 26 services métier (20 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts) │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 14 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1) │ ├── utils/ # 14 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1)
│ ├── i18n/ # Config i18next + locales FR/EN │ ├── i18n/ # Config i18next + locales FR/EN
@ -177,7 +177,7 @@ Les **profils secondaires** (`profile_<id>.db`) sont créés par `get_new_profil
Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`). Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`).
## Services TypeScript (19) ## Services TypeScript (20)
| Service | Responsabilité | | Service | Responsabilité |
|---------|---------------| |---------|---------------|
@ -195,7 +195,8 @@ Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `prof
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) | | `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` | | `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
| `userPreferenceService.ts` | Stockage préférences utilisateur | | `userPreferenceService.ts` | Stockage préférences utilisateur |
| `onboardingService.ts` | Liste de démarrage (#351) : `getOnboardingCounters` lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import et la préférence `onboarding_checklist_hidden` ; `hideChecklist` / `showChecklist` posent et retirent la préférence. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » | | `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden` et les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les six clés) ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `hideChecklist` / `showChecklist` et `skipStep` / `resumeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » |
| `dataEvents.ts` | Canal d'événements de données (#396, [ADR 0022](adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` après chaque écriture réussie d'une table que lit un compteur du parcours de démarrage (liste dans l'ADR), `subscribeDataChanged(listener)` rend le désabonnement. Sans React ni charge utile ; un abonné qui lève est écrit au Journal et n'empêche pas les autres |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine | | `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) | | `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
@ -226,7 +227,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte | | `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
| `useOnboardingChecklist` | Liste de démarrage (#351) : reducer exporté (`onboardingChecklistReducer`, états `loading` / `ready` / `error`), charge le snapshot d'`onboardingService` au montage et le passe à `computeChecklist` avec `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `hide()` / `show()` / `refresh()`. Un échec de lecture garde les compteurs précédents et laisse `hidden` intact — une lecture ratée ne ressuscite jamais une liste masquée ; le Dashboard rend alors la page sans liste et journalise | | `useOnboardingChecklist` | Parcours de démarrage (#351, #396), appelé une seule fois par `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`, monté dans `AppShell` autour de `<Outlet/>`) ; les pages le lisent par `useOnboarding()`. Reducer exporté (`onboardingChecklistReducer`) : chargements numérotés (la réponse d'un chargement autre que le dernier lancé est ignorée), actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`) prioritaires sur le snapshot d'un chargement parti avant elles, rafraîchissement qui garde `ready` et les compteurs, échec après un premier succès qui garde le dernier état. `createOnboardingLoader` attend la règle D9 au premier chargement, une fois (promesse partagée contre le double montage de StrictMode), et n'écrit au Journal qu'au passage en échec. Relecture au montage, à chaque changement de route et 150 ms après une rafale d'événements de données (`followDataChanges`, [ADR 0022](adr/0022-canal-evenements-donnees.md)). `computeChecklist` reçoit `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total) et les étapes passées ; `deriveOnboardingView` (pur) en tire l'étape suivante, le verrou de carte (`latchCardPage`) et le mode (`bannerMode`, `utils/onboardingPath.ts`). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `nextStep`, `mode`, `cardPage`, `hide()` / `show()` / `skip()` / `resume()` / `refresh()` |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) | | `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
| `useHighlights` | Panneau de faits saillants du hub rapports | | `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) | | `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
@ -606,3 +607,4 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted | | [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |
| [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted | | [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted |
| [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted | | [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted |
| [0022](adr/0022-canal-evenements-donnees.md) | Canal d'événements de données : les écritures signalent, le parcours de démarrage relit | 2026-10-08 | Accepted |