Compare commits
7 commits
5c18fddfc0
...
ee17f31352
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee17f31352 | ||
|
|
abc9076d23 | ||
|
|
ba71a6421d | ||
|
|
74a6572475 | ||
|
|
ec3b5d5185 | ||
|
|
696b83fb10 | ||
|
|
a58f8b98bc |
22 changed files with 298 additions and 44 deletions
|
|
@ -6,8 +6,8 @@
|
|||
|
||||
- Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites ou passées, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397).
|
||||
- Des cartes de démarrage sur les pages Import, Transactions et Budget. Tant que l'étape suivante du parcours se fait sur l'une de ces pages, une carte y prend la place du bandeau : elle indique « Étape n sur N », dit quoi faire et désigne le contrôle à utiliser par un contour qui pulse trois fois, et pas du tout quand le système demande de réduire les animations. Quand l'étape est faite, la carte reste, annonce « Fait. » et propose « Étape suivante », qui mène à la page de l'étape d'après, ou la fin du parcours. Arriver sur une page par un lien « Étape suivante » place le focus du clavier sur le titre de la carte. « Passer cette étape » marque l'étape comme passée dans la liste de démarrage, où « Reprendre » l'annule (#398, #399, #400).
|
||||
- Sur la page Import, la carte de démarrage suit l'assistant d'import écran par écran, à travers ses trois étapes (choisir un dossier d'import, configurer une source d'import, importer un premier fichier), et annonce « Fait. » une fois le fichier importé, mais pas sur le rapport d'un import qui n'a ajouté aucune transaction (#398).
|
||||
- Sur la page Transactions, le lien de l'étape « Classer une transaction à la main » ouvre la page sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer, et la carte désigne la catégorie de la première transaction affichée. En édition Gratuite, qui n'a pas d'étape Budget, classer une transaction termine le parcours (#399).
|
||||
- Sur la page Import, la carte de démarrage suit l'assistant d'import écran par écran, à travers ses trois étapes (choisir un dossier d'import, configurer une source d'import, importer un premier fichier), et annonce « Fait. » une fois le fichier importé, jamais pour un import qui n'a ajouté aucune transaction (#398).
|
||||
- Sur la page Transactions, le lien de l'étape « Classer une transaction à la main » ouvre la page sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer, et la carte désigne la catégorie de la première transaction affichée. En édition Gratuite, où l'étape Budget est verrouillée, classer une transaction termine le parcours (#399).
|
||||
- Sur la page Budget, dont la grille s'ouvre catégories repliées, la carte de démarrage désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir, et ne déplie jamais la grille à votre place. Elle rappelle qu'Entrée enregistre, et que Tab enregistre et passe au mois suivant. Enregistrer un montant termine le parcours (#400).
|
||||
- Une sous-section « Le parcours de démarrage » dans la section « Premiers pas » du guide d'utilisation, celle qu'ouvre « Ouvrir le guide » à la fin du parcours : le bandeau et sa liste, les cartes d'étape et leur contour, « Passer cette étape » et « Reprendre », la fin du parcours, les profils qui le trouvent masqué d'office, et son réaffichage depuis Paramètres → Utilisateurs. Les sections Tableau de bord, Profils et Paramètres du guide décrivent le parcours tel qu'il fonctionne désormais (#401).
|
||||
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
### Corrigé
|
||||
|
||||
- La page Import n'affiche plus un bref instant l'état vide « Aucun dossier d'import » à son ouverture, pendant la lecture du dossier d'import enregistré (#398).
|
||||
- L'étape de démarrage « Importer un premier fichier » ne se coche plus après un import qui n'a ajouté aucune transaction, par exemple un fichier réduit à sa ligne d'en-tête, ou dont toutes les lignes étaient en erreur dans l'aperçu : l'étape attend désormais un fichier qui a amené au moins une transaction (#398).
|
||||
- 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).
|
||||
- Les profils autres que le profil par défaut reçoivent désormais les mises à jour de leur base de données. Jusqu'ici, chacun gardait la structure de base de la version qui l'avait créé, et des fonctions ajoutées depuis pouvaient y échouer : dans un profil créé avant la 0.15.0, par exemple, le format d'import enregistré sur une source. À l'ouverture d'un tel profil, l'application garde d'abord une copie de sa base dans le dossier `migration-backups`, à côté des bases, puis applique les mises à jour manquantes. La copie est supprimée avec le profil, et chaque mise à jour est inscrite dans les journaux (Paramètres → Système) (#390).
|
||||
- Sous Linux, supprimer un profil supprime désormais sa base de données, qui restait sur le disque dans `~/.config/com.simpl.resultat/` ; le fichier d'un profil supprimé avec la version 0.17.0 ou antérieure peut être supprimé à la main. Les colonnes de rendement des comptes du Bilan affichent désormais les rendements sous Linux, où chaque cellule affichait un tiret (#390).
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
|
||||
- A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done or skipped, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397).
|
||||
- Getting-started cards on the Import, Transactions and Budget pages. While the next step of the path is done on one of these pages, a card takes the place of the banner there: it shows "Step n of N", says what to do and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. When the step gets done, the card stays, says "Done." and offers "Next step", which leads to the page of the following step, or the end of the path. Arriving on a page by a "Next step" link puts the keyboard focus on the card's title. "Skip this step" marks the step as skipped in the getting-started list, where "Resume" takes it back (#398, #399, #400).
|
||||
- On the Import page, the getting-started card follows the import wizard screen by screen through its three steps (choose an import folder, set up an import source, import a first file) and says "Done." once the file is imported, but not on the report of an import that added no transaction (#398).
|
||||
- On the Transactions page, the link of the "Categorize a transaction by hand" step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized, and the card points at the category of the first transaction shown. In the Free edition, which has no Budget step, categorizing a transaction ends the path (#399).
|
||||
- On the Import page, the getting-started card follows the import wizard screen by screen through its three steps (choose an import folder, set up an import source, import a first file) and says "Done." once the file is imported, never for an import that added no transaction (#398).
|
||||
- On the Transactions page, the link of the "Categorize a transaction by hand" step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized, and the card points at the category of the first transaction shown. In the Free edition, where the Budget step is locked, categorizing a transaction ends the path (#399).
|
||||
- On the Budget page, whose grid opens with its categories folded, the getting-started card first points at "Expand all", then at the current month's cell of the first category you can fill in, and never unfolds the grid for you. It reminds you that Enter saves, and that Tab saves and moves to the next month. Saving an amount ends the path (#400).
|
||||
- A sub-section, "The getting-started path", in the "Getting Started" section of the user guide, the one "Open the guide" opens at the end of the path: the banner and its list, the step cards and their outline, "Skip this step" and "Resume", the end of the path, the profiles that find it hidden from the start, and how to show it again from Settings → Users. The Dashboard, Profiles and Settings sections of the guide describe the path as it now works (#401).
|
||||
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
### Fixed
|
||||
|
||||
- Opening the Import page no longer flashes the "No import folder" empty state while the saved import folder is being read (#398).
|
||||
- The "Import a first file" getting-started step is no longer checked by an import that added no transaction, such as a file holding only its header line, or one whose rows were all in error in the preview: the step now waits for a file that brought at least one transaction (#398).
|
||||
- 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).
|
||||
- Profiles other than the default one now receive database updates. Until now each one kept the database layout of the version that created it, so features added since could fail in it: in a profile created before 0.15.0, for instance, the import format saved on a source. When such a profile is opened, the app first keeps a copy of its database in the `migration-backups` folder next to the databases, then applies the missing updates. The copy is deleted with the profile, and each update is written to the logs (Settings → System) (#390).
|
||||
- On Linux, deleting a profile now deletes its database, which used to stay on disk in `~/.config/com.simpl.resultat/`; the file of a profile deleted with version 0.17.0 or earlier can be deleted by hand. The return columns of the Balance sheet accounts now show the returns on Linux, where every cell showed a dash (#390).
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ Cette liste ne doit pas rétrécir. Sans `insertBatch` ni `updateFileStatus`, la
|
|||
Ne notifient pas :
|
||||
|
||||
- `updateSource`, qui ne change aucun compte ; les notes d'une transaction ; les modèles de budget ; toute écriture d'une table que ne lit aucun compteur ;
|
||||
- `categoryMigrationService.applyMigration`, qui réécrit `budget_entries` (`DELETE` des mois en double sous la catégorie cible, puis `UPDATE` de la catégorie) : il ne tourne que sur sa propre page, la migration des catégories, et la relecture qui suit tout changement de route rattrape le compteur ;
|
||||
- `transactionService.deleteTransaction` (`DELETE FROM transactions`), sans appelant aujourd'hui : son premier appelant l'ajoute au tableau, avec son test ;
|
||||
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, et `initializeGuidedPath`, la règle D9). Le fournisseur en est le seul écrivain et met son état à jour lui-même.
|
||||
|
||||
**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é.
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
|||
| Créer un profil | toujours cochée — la liste n'existe que dans un profil ouvert |
|
||||
| Choisir un dossier d'import | préférence `import_folder` non vide |
|
||||
| Configurer une source d'import | `COUNT(*) FROM import_sources` ≥ 1 |
|
||||
| Importer un premier fichier | `COUNT(*) FROM imported_files WHERE status = 'completed'` ≥ 1 |
|
||||
| Importer un premier fichier | `COUNT(*) FROM imported_files f WHERE f.status = 'completed' AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)` ≥ 1 — un fichier qui n'a amené aucune transaction (toutes ses lignes en erreur, ou sa seule ligne d'en-tête) ne compte pas : `executeImport` écrit la ligne `completed` avant d'insérer |
|
||||
| Classer une transaction à la main | `COUNT(*) FROM transactions WHERE is_manually_categorized = 1` ≥ 1 — ni « règle créée » ni « transactions catégorisées » : le seed IPC pose 180 mots-clés dans tout profil neuf et le wizard catégorise pendant l'import, ces deux cases seraient donc cochées d'avance |
|
||||
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (5 au lieu de 6) et renvoyée vers `/settings/users` |
|
||||
|
||||
|
|
@ -348,7 +348,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
|||
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des six clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
|
||||
| `onboarding_guided_initialized` | `"1"` une fois la règle D9 appliquée au profil | `initializeGuidedPath` |
|
||||
|
||||
**La règle D9.** Au premier chargement, `createOnboardingLoader` attend `initializeGuidedPath`, une fois par fournisseur (promesse partagée contre le double montage de StrictMode). Dans une transaction, si le drapeau `onboarding_guided_initialized` manque, un profil qui a déjà des transactions reçoit `onboarding_checklist_hidden = "1"`, avec une ligne au Journal, puis le drapeau est posé. Un échec annule les deux écritures et le chargement continue sur l'état stocké ; le drapeau absent fait réessayer à l'ouverture suivante. Un profil vide ou neuf garde donc le parcours, et un profil déjà actif le trouve masqué, réaffichable depuis Paramètres → Utilisateurs.
|
||||
**La règle D9.** Au premier chargement, `createOnboardingLoader` attend `initializeGuidedPath`, une fois par fournisseur (promesse partagée contre le double montage de StrictMode). Le drapeau `onboarding_guided_initialized` est d'abord lu hors transaction : une fois posé, chaque ouverture suivante coûte cette lecture et n'ouvre aucune transaction. S'il manque, une transaction le relit, puis un profil qui a déjà des transactions reçoit `onboarding_checklist_hidden = "1"`, et le drapeau est posé. Après le `COMMIT`, et seulement si le parcours vient d'être masqué, une ligne est écrite au Journal. Un échec annule les deux écritures et le chargement continue sur l'état stocké ; le drapeau absent fait réessayer à l'ouverture suivante. Un profil vide ou neuf garde donc le parcours, et un profil déjà actif le trouve masqué, réaffichable depuis Paramètres → Utilisateurs.
|
||||
|
||||
**Les modèles purs** (`src/utils/onboardingPath.ts`, sans React, routeur ni base) :
|
||||
|
||||
|
|
@ -358,7 +358,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
|||
- `bannerMode` : `none` avant le premier chargement réussi, quand le parcours est masqué et sur le tableau de bord d'un profil sans transaction (sa carte d'accueil porte déjà la liste) ; `card` sur la page verrouillée ; `complete` quand toutes les lignes déverrouillées sont faites ou passées, une fois l'entitlement Budget connu (`budgetReady`) ; `banner` partout ailleurs ;
|
||||
- `stepNavState` et `readOnboardingNavState` : l'état de navigation de tout lien vers une étape (`{ onboarding: { focus: true } }`, plus `guided: true` pour « Classer une transaction à la main »), et la seule lecture admise de `location.state` (CWE-476) ;
|
||||
- `showsStepCard`, `cardStep` (l'étape montrée et « Étape n sur N ») et `cardNext` (après « Fait. » : le lien vers l'étape suivante, la fin, ou rien tant que la relecture n'est pas arrivée) ;
|
||||
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; sur le rapport, « Fait. » seulement si l'import a ajouté des transactions), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») et `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`) ;
|
||||
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; « Fait. » seulement pour un import qui a ajouté des transactions, sur le rapport comme au repos, où le compteur ne retient que les fichiers qui en ont amené), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») et `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`) ;
|
||||
- `onboardingTargetAttributes` : les attributs du contrôle mis en évidence.
|
||||
|
||||
`deriveOnboardingView` (`useOnboardingChecklist.ts`, pur) les compose à chaque rendu du fournisseur : étape suivante, verrou de carte et mode.
|
||||
|
|
|
|||
|
|
@ -35,13 +35,13 @@ Les six étapes ci-dessous sont celles du parcours de démarrage. La carte d'acc
|
|||
2. « Voir la liste » déplie les six étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
|
||||
3. Sur les pages Import, Transactions et Budget, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte
|
||||
4. La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place
|
||||
5. Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; sur le rapport d'un import qui n'a ajouté aucune transaction, elle ne l'annonce pas, et le rapport dit pourquoi
|
||||
5. Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire
|
||||
6. « Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page
|
||||
7. Quand toutes les étapes sont faites ou passées, le bandeau, ou la carte de la dernière étape, annonce « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. » « Ouvrir le guide » mène à cette section Premiers pas, et « Fermer » masque le parcours
|
||||
8. « Masquer », dans le bandeau ou dans la liste, masque le bandeau et la liste pour ce profil seulement. Pour les retrouver, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages. Ce bouton est visible dès que le parcours est masqué
|
||||
9. Un profil qui contenait déjà des transactions avant l'arrivée du parcours guidé le voit masqué d'office à sa première ouverture, comme après « Masquer » ; Paramètres → Utilisateurs le réaffiche. Un profil vide ou neuf le garde
|
||||
|
||||
Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 5/5.
|
||||
Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 5/5.
|
||||
|
||||
### Astuces
|
||||
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@
|
|||
intro: "Sur un profil où le parcours n'est pas terminé, par exemple un second profil neuf (clé Base ou plus).",
|
||||
steps: [
|
||||
{ id: "guided-list", title: "« Voir la liste » et « Fermer la liste »",
|
||||
expect: "Sur une page sans carte (Catégories, par exemple), « Voir la liste » déplie les six étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page." },
|
||||
expect: "Sur une page sans carte (Catégories, par exemple), « Voir la liste » déplie les six étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
|
||||
{ id: "guided-skip-resume", title: "« Passer cette étape », puis « Reprendre »",
|
||||
expect: "Sur la carte Transactions ou Budget, clique « Passer cette étape » : la carte laisse la place au bandeau (ou au message de fin si c'était la dernière étape) et le compteur avance d'une étape. Sur la page Import, passer une étape fait passer la carte à l'étape suivante de la page. Dans la liste dépliée, l'étape passée montre l'icône « passer », le badge « Passée » et « Reprendre ». Depuis une autre page, « Reprendre » rouvre la page de l'étape avec sa carte, le focus sur son titre, et l'étape redevient à faire." },
|
||||
{ id: "guided-hide-users", title: "« Masquer » depuis Paramètres → Utilisateurs",
|
||||
|
|
@ -254,7 +254,11 @@
|
|||
{ id: "guided-free", title: "Édition Gratuite : 5 étapes, fin à 5/5",
|
||||
expect: "Sur une session ou une machine sans clé, avec un profil neuf : le compteur dit 1/5, et la ligne Budget est verrouillée « Base et plus » et mène à la carte Licence. Après le classement d'une transaction, la carte Transactions (« Étape 5 sur 5 ») annonce « Fait. » puis le message de fin : aucune carte Budget, fin à 5/5." },
|
||||
{ id: "guided-noop-import", title: "Un import qui n'ajoute rien",
|
||||
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». À juger : la liste coche quand même « Importer un premier fichier », parce qu'elle compte les fichiers importés et non les transactions, et la carte annonce « Fait. » après « Terminé »." },
|
||||
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». Après « Terminé », la carte reste sur « Étape 4 sur 6 » (« 4 sur 5 » en édition Gratuite), entoure la première source et n'annonce toujours pas « Fait. » ; la liste laisse « Importer un premier fichier » à faire." },
|
||||
{ id: "guided-skip-folder", title: "Limite connue : « Passer » sur l'étape du dossier, sans dossier",
|
||||
expect: "Sur un profil neuf, sur la carte Import « Étape 2 sur 6 · Choisir un dossier d'import », clique « Passer cette étape » sans choisir de dossier. La carte passe à « Étape 3 sur 6 · Configurer une source d'import », mais garde la phrase et le contour du choix du dossier ; un second « Passer » mène à l'étape 4, avec la même phrase, et le bandeau revient au troisième. À juger : les étapes de la page dépendent du dossier." },
|
||||
{ id: "guided-empty-profile", title: "Étapes d'import passées : Transactions et Budget sans transaction",
|
||||
expect: "Sur un profil neuf en édition Base ou plus, sur un build de production (le mode dev masque le cas Budget), choisis un dossier d'import vide, puis passe les étapes 3, 4 et 5 avec « Passer cette étape ». Sur la page Transactions, la carte demande de cliquer sur la catégorie d'une transaction au-dessus de l'état vide « Aucune transaction pour l'instant », que la barre de filtres et un bref chargement peuvent précéder (limite connue, à juger). Sur la page Budget, la carte « Étape 6 sur 6 · Saisir un premier budget » garde son titre et « Passer cette étape », sans phrase ni contour, au-dessus de l'état vide « Le budget se construit sur vos transactions » ; elle ne dit jamais « Cliquez Tout déplier… »." },
|
||||
{ id: "guided-fallback", title: "Transactions : aucune transaction à classer",
|
||||
expect: "Sur un profil de test où l'étape 5 reste à faire, rends toutes les transactions catégorisées sans les classer à la main : ajoute, depuis la page Catégories, un mot-clé pour chaque transaction restée sans catégorie, puis clique « Auto-catégoriser » sur la page Transactions. Depuis une autre page, suis « Étape suivante : Classer une transaction à la main → ». La page montre toutes les transactions, période « Tout », une seule fois, et la carte dit « Cliquez sur la catégorie d'une transaction… ». « Aucune transaction trouvée. » peut s'afficher un très bref instant entre les deux lectures (limite connue)." },
|
||||
{ id: "guided-same-page", title: "Limite connue : un lien d'étape suivi depuis la page Transactions",
|
||||
|
|
@ -262,7 +266,7 @@
|
|||
{ id: "guided-budget-expanded", title: "Budget : grille déjà dépliée par le profil",
|
||||
expect: "Sur un profil où l'étape 6 reste à faire, déplie la grille (« Tout déplier ») sans saisir de montant, puis quitte la page Budget. Reviens par le lien de l'étape 6 : la grille est restée dépliée et la carte entoure directement la cellule du mois courant." },
|
||||
{ id: "guided-budget-offscreen", title: "Limite connue : cellule du mois hors de la zone visible",
|
||||
expect: "En fenêtre étroite, la grille du budget défile horizontalement et la cellule du mois courant peut être hors de vue (octobre est vers la droite) : rien ne défile tout seul. À juger : la phrase et le contour suffisent-ils pour la trouver ?" },
|
||||
expect: "En fenêtre étroite, la grille du budget défile horizontalement et la cellule du mois courant peut être hors de vue (les derniers mois de l'année sont à droite) : rien ne défile tout seul. À juger : la phrase et le contour suffisent-ils pour la trouver ?" },
|
||||
{ id: "guided-budget-zero", title: "Budget : une cellule quittée vide enregistre 0 $",
|
||||
expect: "Sur un profil où l'étape 6 reste à faire, clique une cellule puis quitte-la sans rien saisir : 0 $ est enregistré et la carte annonce « Fait. ». C'est le comportement existant de la grille ; à juger." },
|
||||
{ id: "guided-import-flash", title: "Limite connue : « Aucun dossier configuré » à l'ouverture d'Import",
|
||||
|
|
@ -283,7 +287,9 @@
|
|||
{ id: "guided-contrast", title: "Contraste du contour, thème clair et thème sombre",
|
||||
expect: "Dans les deux thèmes (bouton de la barre latérale), le contour se distingue nettement du fond de la page, des cartes et du contour de focus du clavier, sur les pages Import, Transactions et Budget." },
|
||||
{ id: "guided-keyboard", title: "Clavier seul",
|
||||
expect: "Sans souris, de l'étape 2 à l'étape 6 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Le focus reste visible et n'est jamais pris au piège." },
|
||||
expect: "Sans souris, de l'étape 2 à l'étape 6 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Quand le contrôle entouré a le focus, l'anneau de focus du système remplace le contour. Le focus reste visible partout et n'est jamais pris au piège." },
|
||||
{ id: "guided-focus-after-hide", title: "Limite connue : le focus après « Masquer » et « Fermer »",
|
||||
expect: "Au clavier, active « Masquer » dans le bandeau, puis, sur un autre profil, « Fermer » au message de fin : le bouton disparaît avec le bandeau et le focus retombe sur la page elle-même, comme avec l'ancienne barre compacte ; note d'où Tab repart. Même chose après un lien du bandeau vers une page sans carte (« Ouvrir le guide », ligne Budget verrouillée). À juger, avec Orca puis NVDA : le lecteur d'écran perd-il le fil ?" },
|
||||
{ id: "guided-orca", title: "Annonces d'Orca (Linux)",
|
||||
expect: "Le bandeau et la carte s'annoncent comme la région « Parcours de démarrage » ; « Voir la liste » annonce son état replié ou déplié ; dans la liste, chaque étape est suivie de son état (« faite », « passée », « verrouillée », « en attente »), sauf une étape à faire ; à l'arrivée par « Étape suivante », le titre de la carte est lu ; « Fait. » est annoncé sans déplacer le focus ; la confirmation de Paramètres → Utilisateurs est annoncée." },
|
||||
{ id: "guided-nvda", title: "Annonces de NVDA (Windows)",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// subtotal-position preference is read from a stubbed localStorage. What is
|
||||
// pinned is where the one highlight lands: "Expand all" or this month's cell
|
||||
// of the first editable row shown.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||
|
|
@ -172,9 +172,23 @@ describe("BudgetTable — the getting-started card's target (#400)", () => {
|
|||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
});
|
||||
|
||||
it("the cell follows the clock's month", () => {
|
||||
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
||||
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
||||
describe("in a timezone west of Greenwich", () => {
|
||||
// Under TZ=UTC (the CI runner), 23:00 on December 31 is December both in
|
||||
// local time and in UTC, so a month read in UTC would pass unnoticed. The
|
||||
// zone is pinned as in useImportWizard.test.ts (#356).
|
||||
const originalTz = process.env.TZ;
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "America/Toronto";
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalTz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = originalTz;
|
||||
});
|
||||
|
||||
it("the cell follows the clock's month", () => {
|
||||
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
||||
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
||||
});
|
||||
});
|
||||
|
||||
it("subtotals at the bottom: still the first editable row shown", () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
initTransactionsState,
|
||||
showsEmptyState,
|
||||
GUIDED_ENTRY_FILTERS,
|
||||
type TransactionsAction,
|
||||
type TransactionsState,
|
||||
} from "./useTransactions";
|
||||
import type { TransactionPageResult } from "../shared/types";
|
||||
|
|
@ -170,6 +171,32 @@ describe("transactionsReducer — the guided entry's fallback (#399)", () => {
|
|||
expect(after.guidedFallback).toBe(false);
|
||||
});
|
||||
|
||||
// The actions the hook really sends around the first page: `fetchData` sends
|
||||
// SET_LOADING then SET_ERROR(null) before each read, and the mount effect's
|
||||
// categories, sources and count land before or after that page. None of
|
||||
// them may disarm the fallback (review of #399).
|
||||
it.each([
|
||||
["the mount reads land first", ["mount", "fetch", "page"]],
|
||||
["the first page lands first", ["fetch", "page", "mount"]],
|
||||
] as const)("the hook's real sequence still falls back (%s)", (_name, order) => {
|
||||
const sent: Record<(typeof order)[number], TransactionsAction[]> = {
|
||||
fetch: [
|
||||
{ type: "SET_LOADING", payload: true },
|
||||
{ type: "SET_ERROR", payload: null },
|
||||
],
|
||||
mount: [
|
||||
{ type: "SET_CATEGORIES", payload: [] },
|
||||
{ type: "SET_SOURCES", payload: [] },
|
||||
{ type: "SET_TOTAL_IN_DB", payload: 12 },
|
||||
],
|
||||
page: [{ type: "SET_PAGE_RESULT", payload: page(0) }],
|
||||
};
|
||||
const after = order.flatMap((group) => sent[group]).reduce(transactionsReducer, guided());
|
||||
expect(after.filters.uncategorizedOnly).toBe(false);
|
||||
expect(after.filters.dateFrom).toBeNull();
|
||||
expect(after.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back once: an empty page after the fallback keeps the filters, so nothing reads again", () => {
|
||||
const fellBack = result(guided(), 0);
|
||||
const again = result(fellBack, 0);
|
||||
|
|
|
|||
|
|
@ -900,13 +900,13 @@
|
|||
"\"Show the list\" unfolds the six steps under the banner and \"Close the list\" folds them; the list also folds when you change pages. Each step still to do leads to the page where it is done",
|
||||
"On the Import, Transactions and Budget pages, while the next step is done there, a getting-started card takes the place of the banner. It shows \"Step n of N\", says what to do and puts an outline around the control to use. The outline pulses three times, and not at all if your system asks for reduced motion. When you arrive by a \"Next step\" link, the keyboard focus goes to the card's title",
|
||||
"The card follows the step without you changing pages. On the Import page, it goes along with the wizard screen by screen. The link of the \"Categorize a transaction by hand\" step opens the Transactions page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. On the Budget page, the card first points at \"Expand all\", then at the current month's cell of the first category you can fill in; it never unfolds the grid for you",
|
||||
"As soon as the step is done, the card stays, says \"Done.\" and offers the next step, with a link to its page. On the Import page, it moves from one step to the next without leaving the page and says \"Done.\" once the file is imported; on the report of an import that added no transaction, it does not, and the report says why",
|
||||
"As soon as the step is done, the card stays, says \"Done.\" and offers the next step, with a link to its page. On the Import page, it moves from one step to the next without leaving the page and says \"Done.\" once the file is imported; never for an import that added no transaction: the report says why, and the step stays to do",
|
||||
"\"Skip this step\", on the card, marks the step as skipped: it counts towards your progress, and the list shows it with the \"Skipped\" badge and a \"Resume\" link. \"Resume\" puts the step back to do and opens its page",
|
||||
"Once every step is done or skipped, the banner, or the card of the last step, says \"Getting started complete. The user guide takes it from here.\" \"Open the guide\" leads to this Getting Started section, and \"Close\" hides the path",
|
||||
"\"Hide\", in the banner or in the list, hides the banner and the list for this profile only. To bring them back, open Settings → Users and click \"Show the getting-started checklist\": the path reappears at the top of the pages. The button shows as soon as the path is hidden",
|
||||
"A profile that already had transactions before the guided path arrived has it hidden from its first opening, as if you had clicked \"Hide\"; Settings → Users shows it again. An empty or new profile keeps it"
|
||||
],
|
||||
"note": "Steps tick on their own, from your data and without changing pages: \"Categorize a transaction by hand\" expects a transaction whose category you picked yourself, and the automatic categorization of the import does not tick it. In the Free edition, the Budget step is locked and does not count: the path ends at 5/5."
|
||||
"note": "Steps tick on their own, from your data and without changing pages: \"Import a first file\" expects a file that added at least one transaction, \"Categorize a transaction by hand\" expects a transaction whose category you picked yourself, and the automatic categorization of the import does not tick it. In the Free edition, the Budget step is locked and does not count: the path ends at 5/5."
|
||||
}
|
||||
],
|
||||
"tips": [
|
||||
|
|
|
|||
|
|
@ -900,13 +900,13 @@
|
|||
"« Voir la liste » déplie les six étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire",
|
||||
"Sur les pages Import, Transactions et Budget, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte",
|
||||
"La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place",
|
||||
"Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; sur le rapport d'un import qui n'a ajouté aucune transaction, elle ne l'annonce pas, et le rapport dit pourquoi",
|
||||
"Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire",
|
||||
"« Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page",
|
||||
"Quand toutes les étapes sont faites ou passées, le bandeau, ou la carte de la dernière étape, annonce « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. » « Ouvrir le guide » mène à cette section Premiers pas, et « Fermer » masque le parcours",
|
||||
"« Masquer », dans le bandeau ou dans la liste, masque le bandeau et la liste pour ce profil seulement. Pour les retrouver, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages. Ce bouton est visible dès que le parcours est masqué",
|
||||
"Un profil qui contenait déjà des transactions avant l'arrivée du parcours guidé le voit masqué d'office à sa première ouverture, comme après « Masquer » ; Paramètres → Utilisateurs le réaffiche. Un profil vide ou neuf le garde"
|
||||
],
|
||||
"note": "Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 5/5."
|
||||
"note": "Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 5/5."
|
||||
}
|
||||
],
|
||||
"tips": [
|
||||
|
|
|
|||
|
|
@ -43,8 +43,13 @@ export default function BudgetPage() {
|
|||
|
||||
// The getting-started card follows the grid (#400). Its target is
|
||||
// highlighted only while the card is on screen: a ring needs the card that
|
||||
// explains it.
|
||||
const cardModel = budgetCardModel({ target: gridTarget }, onboarding.checklist);
|
||||
// explains it. The grid's last report does not outlive the grid: the rows
|
||||
// can land before the transaction count (both reads queue behind the DB
|
||||
// lock), so the grid may report, then give way to the empty state.
|
||||
const cardModel = budgetCardModel(
|
||||
{ target: isEmptyProfile ? null : gridTarget },
|
||||
onboarding.checklist,
|
||||
);
|
||||
const highlight =
|
||||
cardModel.target !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname);
|
||||
|
||||
|
|
|
|||
|
|
@ -183,4 +183,29 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
|
|||
it("hands the grid the same callback at every render, so the grid does not report at every render", () => {
|
||||
expect(render().report).toBe(render().report);
|
||||
});
|
||||
|
||||
it("a profile without transactions: the grid's last report does not outlive the grid", () => {
|
||||
// Steps 3 to 5 skipped: the budget step is next on a profile without any
|
||||
// transaction. The rows can land before the count (both reads queue
|
||||
// behind db.ts's lock): the grid mounts, reports "Expand all", then gives
|
||||
// way to the empty state (review of #400).
|
||||
const counters = { ...BUDGET_LEFT, imports: 0, manualCategorized: 0, transactions: 0 };
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: true,
|
||||
budgetAllowed: true,
|
||||
skipped: ["source", "import", "manualCategory"],
|
||||
});
|
||||
harness.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
|
||||
const budget = harness.budget as { state: BudgetState };
|
||||
budget.state = { ...budget.state, totalInDb: null };
|
||||
render().report("expandAll");
|
||||
expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed");
|
||||
|
||||
budget.state = { ...budget.state, totalInDb: 0 };
|
||||
harness.cursor = 0;
|
||||
const page = BudgetPage() as AnyElement;
|
||||
expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0);
|
||||
const [card] = find(page, (el) => el.type === OnboardingStepCard);
|
||||
expect(card.props.model).toEqual({ text: null, done: false, target: null });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -230,7 +230,8 @@ describe("ImportPage — the step card (#398)", () => {
|
|||
scannedSources: SOURCES,
|
||||
importReport: report(0),
|
||||
});
|
||||
// Duplicates only: the file row is `completed`, the checklist counts it.
|
||||
// Duplicates only: this file brought no transaction, and the step stays
|
||||
// checked by the earlier import that brought the ones it duplicates.
|
||||
harness.onboarding = path(IMPORTED, true);
|
||||
const html = render();
|
||||
expect(sentence(html)).toBe("t(onboarding.card.import.failed[])");
|
||||
|
|
@ -238,6 +239,20 @@ describe("ImportPage — the step card (#398)", () => {
|
|||
expect(html).not.toContain("onboarding.card.next");
|
||||
});
|
||||
|
||||
it("after 'Done' on an import that added nothing, the wizard at rest: no 'Done.', step 4 again on the first source card", () => {
|
||||
// The file row is written, but the import step only counts a file that
|
||||
// brought a transaction: the re-read keeps the step to do (review of #398).
|
||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||
harness.onboarding = path({ ...ZERO, sources: 1 }, true);
|
||||
const html = render();
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
|
||||
expect(html).not.toContain("t(onboarding.card.done)");
|
||||
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
|
||||
const target = highlighted(html);
|
||||
expect(target.inner).toContain("Desjardins");
|
||||
expect(target.inner).not.toContain("Tangerine");
|
||||
});
|
||||
|
||||
it("after 'Done' or 'Cancel', the wizard at rest: the card still says 'Done.' and points at nothing", () => {
|
||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||
harness.onboarding = path(IMPORTED, true);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { TransactionRow } from "../shared/types";
|
||||
import type { TransactionFilters, TransactionRow } from "../shared/types";
|
||||
|
||||
// The page's services pull the database: stub the connection.
|
||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||
|
|
@ -21,7 +21,12 @@ vi.mock("react-i18next", () => ({
|
|||
}));
|
||||
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||
|
||||
const harness = vi.hoisted(() => ({ onboarding: null as unknown, rows: [] as TransactionRow[] }));
|
||||
const harness = vi.hoisted(() => ({
|
||||
onboarding: null as unknown,
|
||||
rows: [] as TransactionRow[],
|
||||
/** Filters a later dispatch would have set (the fallback, a filter picked by hand). */
|
||||
filters: null as Partial<TransactionFilters> | null,
|
||||
}));
|
||||
|
||||
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||
vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
||||
|
|
@ -30,7 +35,8 @@ vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
|||
...actual,
|
||||
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
|
||||
const hook = actual.useTransactions(...args);
|
||||
return { ...hook, state: { ...hook.state, rows: harness.rows } };
|
||||
const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters;
|
||||
return { ...hook, state: { ...hook.state, rows: harness.rows, filters } };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -122,6 +128,7 @@ const activePeriod = (html: string) =>
|
|||
beforeEach(() => {
|
||||
harness.onboarding = path(IMPORTED);
|
||||
harness.rows = [];
|
||||
harness.filters = null;
|
||||
});
|
||||
|
||||
describe("TransactionsPage — the guided entry (#399)", () => {
|
||||
|
|
@ -165,6 +172,19 @@ describe("TransactionsPage — the step card (#399)", () => {
|
|||
expect(sentence(render())).toBe("t(onboarding.card.transactions.all[])");
|
||||
});
|
||||
|
||||
// The sentence follows the list's filter, not the navigation state.
|
||||
it("the guided entry after its fallback: the sentence for every transaction", () => {
|
||||
harness.filters = { uncategorizedOnly: false };
|
||||
expect(sentence(render(GUIDED))).toBe("t(onboarding.card.transactions.all[])");
|
||||
});
|
||||
|
||||
it("an ordinary visit with 'Uncategorized' picked by hand: the uncategorized sentence", () => {
|
||||
harness.filters = { uncategorizedOnly: true };
|
||||
expect(sentence(render())).toBe(
|
||||
"t(onboarding.card.transactions.uncategorized[filter=t(transactions.filters.uncategorized)])",
|
||||
);
|
||||
});
|
||||
|
||||
it("the first row's category is the one target", () => {
|
||||
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
|
||||
const html = render(GUIDED);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ vi.mock("./db", () => {
|
|||
};
|
||||
});
|
||||
|
||||
import { getDb } from "./db";
|
||||
import { getDb, withTransaction } from "./db";
|
||||
import {
|
||||
getExportImportSources,
|
||||
getExportImportTemplates,
|
||||
|
|
@ -803,6 +803,46 @@ describe("a committed restore is announced on the data channel (#396)", () => {
|
|||
expect(seen[0][seen[0].length - 1]).toBe("COMMIT");
|
||||
});
|
||||
|
||||
// ADR 0022: a write notifies once `withTransaction` has returned, never from
|
||||
// its callback, where the DB lock is still held.
|
||||
it.each([
|
||||
[
|
||||
"transactions with categories",
|
||||
() =>
|
||||
importTransactionsWithCategories(
|
||||
envelopeData({ transactions: [transactionFixture(1)] }),
|
||||
"backup.json"
|
||||
),
|
||||
],
|
||||
[
|
||||
"transactions only",
|
||||
() => importTransactionsOnly(envelopeData({ transactions: [transactionFixture(1)] }), "backup.json"),
|
||||
],
|
||||
[
|
||||
"categories only",
|
||||
() => importCategoriesOnly({ categories: CATEGORY_FIXTURES, suppliers: [], keywords: [] }),
|
||||
],
|
||||
] as const)("%s: notifies once the DB lock is released", async (_name, restore) => {
|
||||
let inLock = false;
|
||||
const locks: boolean[] = [];
|
||||
const stop = subscribeDataChanged(() => locks.push(inLock));
|
||||
vi.mocked(withTransaction).mockImplementation(async (fn) => {
|
||||
inLock = true;
|
||||
try {
|
||||
return await fn((await getDb()) as never);
|
||||
} finally {
|
||||
inLock = false;
|
||||
}
|
||||
});
|
||||
try {
|
||||
await restore();
|
||||
} finally {
|
||||
stop();
|
||||
vi.mocked(withTransaction).mockImplementation(async (fn) => fn((await getDb()) as never));
|
||||
}
|
||||
expect(locks).toEqual([false]);
|
||||
});
|
||||
|
||||
it("a restore rolled back announces nothing", async () => {
|
||||
let txInserts = 0;
|
||||
wire(
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ 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).
|
||||
// Both branches write the status that the "import a first file" step reads
|
||||
// (`status = 'completed'`, counted once the file brought a transaction), 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`,
|
||||
|
|
|
|||
|
|
@ -131,7 +131,6 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
|||
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/);
|
||||
|
|
@ -144,6 +143,21 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
|||
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The tests run without SQLite, so the rule is pinned on the query itself.
|
||||
// `executeImport` writes the file row `completed` before its first insert: a
|
||||
// file whose rows were all in error keeps that row and adds no transaction,
|
||||
// and must not tick "Import a first file" (review of #398).
|
||||
it("counts a completed import only once the file brought a transaction", async () => {
|
||||
const { db } = useFakeDb();
|
||||
await getOnboardingSnapshot();
|
||||
const aggregate = db.select.mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.find((s) => s.includes("import_sources"))!;
|
||||
expect(aggregate).toMatch(
|
||||
/\(SELECT COUNT\(\*\) FROM imported_files f\s+WHERE f\.status = 'completed'\s+AND EXISTS \(SELECT 1 FROM transactions t WHERE t\.file_id = f\.id\)\)\s+AS imports/,
|
||||
);
|
||||
});
|
||||
|
||||
it("is a pure read: it never writes, even on a profile D9 would hide", async () => {
|
||||
const { db } = useFakeDb({ transactions: 60 });
|
||||
const unsubscribe = subscribeDataChanged(() => {
|
||||
|
|
@ -246,9 +260,19 @@ describe("initializeGuidedPath — rule D9 (#396)", () => {
|
|||
["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]);
|
||||
// The flag is read once outside any transaction, then again after BEGIN,
|
||||
// with the transaction count.
|
||||
const begin = fake.db.execute.mock.invocationCallOrder[0];
|
||||
const reads = fake.db.select.mock.calls.map(([sql], i) => ({
|
||||
sql: String(sql),
|
||||
order: fake.db.select.mock.invocationCallOrder[i],
|
||||
}));
|
||||
const flagReads = reads.filter((r) => r.sql.includes("FROM user_preferences WHERE key = $1"));
|
||||
expect(flagReads).toHaveLength(2);
|
||||
expect(flagReads[0].order).toBeLessThan(begin);
|
||||
expect(flagReads[1].order).toBeGreaterThan(begin);
|
||||
const count = reads.find((r) => r.sql.includes("COUNT(*) AS transactions FROM transactions"))!;
|
||||
expect(count.order).toBeGreaterThan(begin);
|
||||
expect(fake.prefs()).toEqual({
|
||||
[ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1",
|
||||
[ONBOARDING_GUIDED_INITIALIZED_KEY]: "1",
|
||||
|
|
@ -264,17 +288,57 @@ describe("initializeGuidedPath — rule D9 (#396)", () => {
|
|||
expect(logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("with the flag already set: no write at all, even with transactions", async () => {
|
||||
it("with the flag already set: no transaction and no write, 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(withTransaction).not.toHaveBeenCalled();
|
||||
expect(fake.statements).toEqual([]);
|
||||
expect(fake.db.select).toHaveBeenCalledTimes(1);
|
||||
expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
|
||||
expect(logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the flag as set only for the value "1", outside and inside the transaction', async () => {
|
||||
for (const value of ["0", "true", ""]) {
|
||||
vi.mocked(withTransaction).mockClear();
|
||||
const fake = useFakeDb({
|
||||
transactions: 3,
|
||||
prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: value },
|
||||
});
|
||||
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: true });
|
||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(fake.statements).toEqual([
|
||||
"BEGIN",
|
||||
"INSERT INTO user_preferences",
|
||||
"INSERT INTO user_preferences",
|
||||
"COMMIT",
|
||||
]);
|
||||
expect(fake.prefs()[ONBOARDING_GUIDED_INITIALIZED_KEY]).toBe("1");
|
||||
}
|
||||
});
|
||||
|
||||
it("re-reads the flag inside the transaction: a first load that set it in between wins (CWE-362)", async () => {
|
||||
const fake = useFakeDb({ transactions: 3 });
|
||||
const read = fake.db.select.getMockImplementation()!;
|
||||
let flagReads = 0;
|
||||
fake.db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
|
||||
const rows = await read(sql, params);
|
||||
if (params?.[0] === ONBOARDING_GUIDED_INITIALIZED_KEY && ++flagReads === 1) {
|
||||
// Another opening runs D9 to its end right after this read.
|
||||
fake.prefs()[ONBOARDING_GUIDED_INITIALIZED_KEY] = "1";
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
||||
expect(flagReads).toBe(2);
|
||||
expect(fake.statements).toEqual(["BEGIN", "COMMIT"]);
|
||||
expect(fake.prefs()[ONBOARDING_CHECKLIST_HIDDEN_KEY]).toBeUndefined();
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -54,10 +54,16 @@ interface CountersRow {
|
|||
transactions: number;
|
||||
}
|
||||
|
||||
// `imports` counts a `completed` file only once it brought a transaction:
|
||||
// `executeImport` writes the row `completed` before inserting, and a file whose
|
||||
// rows were all in error (or that holds only its header) keeps that row with
|
||||
// no transaction. The sub-query is covered by `idx_transactions_file`.
|
||||
const COUNTERS_SQL = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM import_sources) AS sources,
|
||||
(SELECT COUNT(*) FROM imported_files WHERE status = 'completed') AS imports,
|
||||
(SELECT COUNT(*) FROM imported_files f
|
||||
WHERE f.status = 'completed'
|
||||
AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)) AS imports,
|
||||
(SELECT COUNT(*) FROM transactions WHERE is_manually_categorized = 1) AS manual_categorized,
|
||||
(SELECT COUNT(*) FROM budget_entries) AS budget_entries,
|
||||
(SELECT COUNT(*) FROM transactions) AS transactions
|
||||
|
|
@ -117,15 +123,24 @@ export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
|
|||
* 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.
|
||||
* it.
|
||||
*
|
||||
* The flag is read first, outside any transaction: once D9 ran, every later
|
||||
* opening costs that one queued read and opens no transaction (while a
|
||||
* `withTransaction` callback runs, `db.ts` lets every other query bypass its
|
||||
* lock). Without the flag, D9 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. Only the value "1" counts as set, on both reads.
|
||||
*
|
||||
* 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 }> {
|
||||
if ((await getPreference(ONBOARDING_GUIDED_INITIALIZED_KEY)) === "1") {
|
||||
return { hidden: false };
|
||||
}
|
||||
const hidden = await withTransaction(async (db) => {
|
||||
await db.execute("BEGIN");
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -960,6 +960,7 @@ export interface AccountReturn {
|
|||
/** Aggregated counters behind the getting-started checklist, read in one query. */
|
||||
export interface OnboardingCounters {
|
||||
sources: number;
|
||||
/** `completed` imported files that brought at least one transaction. */
|
||||
imports: number;
|
||||
manualCategorized: number;
|
||||
budgetEntries: number;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
// - WCAG 1.4.11: the ring reaches 3:1 against the page background and the
|
||||
// cards, in the light theme (`:root`) and the dark one (`.dark`);
|
||||
// - WCAG 2.2.2: the pulse stops by itself after three cycles, within 5 s;
|
||||
// - the pulse does not run under `prefers-reduced-motion: reduce`.
|
||||
// - the pulse does not run under `prefers-reduced-motion: reduce`;
|
||||
// - WCAG 2.4.7: the browser's focus ring replaces the ring on keyboard focus.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
|
@ -161,4 +162,10 @@ describe("styles.css — the getting-started highlight (#398)", () => {
|
|||
expect(inner, "the highlight rule under reduced motion").toBeDefined();
|
||||
expect(declarations(inner!.body).get("animation")).toBe("none");
|
||||
});
|
||||
|
||||
it("gives the browser's focus ring back to the highlighted control (WCAG 2.4.7)", () => {
|
||||
const rule = TOP.find((b) => b.selector === `${HIGHLIGHT}:focus-visible`);
|
||||
expect(rule, "the focus-visible rule").toBeDefined();
|
||||
expect(declarations(rule!.body).get("outline")).toBe("revert");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -164,6 +164,16 @@ body {
|
|||
animation-iteration-count: 3;
|
||||
}
|
||||
|
||||
/*
|
||||
* An author outline wins over the browser's focus ring: without this rule, a
|
||||
* keyboard user who reaches the highlighted control would see nothing change
|
||||
* (WCAG 2.4.7). The browser's ring comes back while the control has the
|
||||
* keyboard focus.
|
||||
*/
|
||||
[data-onboarding-highlight="true"]:focus-visible {
|
||||
outline: revert;
|
||||
}
|
||||
|
||||
@keyframes onboarding-pulse {
|
||||
from {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
|
|
|
|||
|
|
@ -280,8 +280,8 @@ function checked(checklist: Checklist | null, key: ChecklistItemKey): boolean {
|
|||
* are tried in order, and the first that applies wins:
|
||||
* 1. the folder is not read yet: no card (null);
|
||||
* 2. an import is running: "Importing…". It comes before anything the
|
||||
* counters say, because `executeImport` writes the `imported_files` row
|
||||
* `completed` before inserting a single transaction;
|
||||
* counters say, because the import step ticks as soon as the first
|
||||
* transaction of the file lands, long before the import ends;
|
||||
* 3. the report: "Done." when the import added transactions, the failure
|
||||
* sentence when it added none (insertion failed, duplicates only), or when
|
||||
* the count is missing: never "Done." without one;
|
||||
|
|
|
|||
Loading…
Reference in a new issue