docs(onboarding): user guide, architecture and QA checklist for the guided getting-started path #407
25 changed files with 805 additions and 173 deletions
|
|
@ -4,20 +4,23 @@
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
||||||
- 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, 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).
|
- 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).
|
||||||
- Une carte de démarrage sur la page Import, à la place du bandeau, tant que l'étape suivante du parcours s'y fait (choisir un dossier d'import, configurer une source d'import, importer un premier fichier). Elle indique « Étape n sur N », dit quoi faire à chaque écran de l'assistant d'import 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. Elle suit l'assistant pas à pas, annonce « Fait. » une fois le fichier importé et propose « Étape suivante », qui mène à la page de l'étape d'après ; elle n'annonce pas « Fait. » pour un import qui n'a ajouté aucune transaction. Arriver sur la page Import 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).
|
- 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).
|
||||||
- Une carte de démarrage sur la page Transactions, à la place du bandeau, tant que l'étape suivante du parcours est de classer une transaction à la main. Le lien de cette étape 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. La carte dit quoi faire et désigne la catégorie de la première transaction affichée. Choisir une catégorie coche l'étape : la carte annonce « Fait. » et propose « Saisir un premier budget », ou la fin du parcours en édition Gratuite (#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).
|
||||||
- Une carte de démarrage sur la page Budget, à la place du bandeau, tant que l'étape suivante du parcours est de saisir un premier budget. La grille du budget s'ouvre catégories repliées : la carte 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 coche l'étape : la carte annonce « Fait. » et termine le parcours, avec « Ouvrir le guide » et « Fermer » (#400).
|
- 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).
|
||||||
|
|
||||||
### Modifié
|
### 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).
|
- Le parcours 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, le bandeau et sa liste sont masqués, comme après un clic sur « Masquer », et « Afficher la liste de démarrage », dans Paramètres → Utilisateurs, les fait revenir. Un profil sans transaction les garde (#396).
|
||||||
- Le tableau de bord n'affiche plus la barre compacte de démarrage une fois que le profil a des transactions : le bandeau la remplace sur chaque page. Dans la liste détaillée, les lecteurs d'écran annoncent désormais si chaque étape est faite, verrouillée ou en attente (#397).
|
- Le tableau de bord n'affiche plus la barre compacte de démarrage une fois que le profil a des transactions : le bandeau la remplace sur chaque page. Dans la liste détaillée, les lecteurs d'écran annoncent désormais si chaque étape est faite, passée, verrouillée ou en attente (#397).
|
||||||
- Paramètres → Utilisateurs propose « Afficher la liste de démarrage » dès que le parcours est masqué, y compris depuis le bandeau de cette même page, et confirme désormais que le parcours réapparaît en haut des pages (#397).
|
- Paramètres → Utilisateurs propose « Afficher la liste de démarrage » dès que le parcours est masqué, y compris depuis le bandeau de cette même page, et confirme désormais que le parcours réapparaît en haut des pages (#397).
|
||||||
|
|
||||||
### Corrigé
|
### 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).
|
- 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).
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|
|
||||||
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -4,20 +4,23 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 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, 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).
|
- 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).
|
||||||
- A getting-started card on the Import page, in place of the banner, while the next step of the path is done there (choose an import folder, set up an import source, import a first file). It shows "Step n of N", says what to do on each screen of the import wizard and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. It follows the wizard as you go, says "Done." once the file is imported and offers "Next step", which leads to the page of the following step; it does not say "Done." for an import that added no transaction. Arriving on the Import 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).
|
- 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).
|
||||||
- A getting-started card on the Transactions page, in place of the banner, while the next step of the path is to categorize a transaction by hand. The link of that step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. The card says what to do and points at the category of the first transaction shown. Choosing a category ticks the step: the card says "Done." and offers "Enter a first budget", or the end of the path in the Free edition (#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).
|
||||||
- A getting-started card on the Budget page, in place of the banner, while the next step of the path is to enter a first budget. The budget grid opens with its categories folded: the 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 ticks the step: the card says "Done." and ends the path, with "Open the guide" and "Close" (#400).
|
- 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).
|
||||||
|
|
||||||
### Changed
|
### 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).
|
- The getting-started path no longer shows in a profile that already had transactions before this version: the first time such a profile is opened, the banner and its checklist are hidden, as if you had clicked "Hide", and "Show the getting-started checklist" in Settings → Users brings them back. A profile without transactions keeps them (#396).
|
||||||
- The dashboard no longer shows the compact getting-started bar once the profile has transactions: the banner replaces it on every page. In the detailed list, screen readers now hear whether each step is done, locked or pending (#397).
|
- The dashboard no longer shows the compact getting-started bar once the profile has transactions: the banner replaces it on every page. In the detailed list, screen readers now hear whether each step is done, skipped, locked or pending (#397).
|
||||||
- Settings → Users offers "Show the getting-started checklist" as soon as the path is hidden, also when it was hidden from the banner of that same page, and now confirms that the path reappears at the top of the pages (#397).
|
- Settings → Users offers "Show the getting-started checklist" as soon as the path is hidden, also when it was hidden from the banner of that same page, and now confirms that the path reappears at the top of the pages (#397).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Opening the Import page no longer flashes the "No import folder" empty state while the saved import folder is being read (#398).
|
- 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).
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|
|
||||||
1
STATE.md
1
STATE.md
|
|
@ -65,6 +65,7 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
|
||||||
- **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop qu'attendait le go-live est publiée (v0.16.0, 2026-10-03).** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. **Volet serveur clos le 2026-10-03** (derniers commentaires relus le 2026-10-04) : maximus-api#62 fermée, tous critères cochés (maximus-api 0.4.2 en prod, secrets en runtime seul), avec la milestone `spec-paiements` de maximus-api (12/12) ; la-compagnie-maximus#150 (Link dans les textes légaux) livrée et fermée le 2026-10-01. **Clôture du go-live = la-compagnie-maximus#141** (`status:blocked`), qui n'attend plus que ce dépôt : l'updater vérifié (Free refuse et invite à acheter, Base accepte), #350 étant fermée depuis le 2026-10-04. la-compagnie-maximus#184 (politique de confidentialité : données d'activation, contrôle au démarrage) est en `status:review` ; sa PR reste en brouillon jusqu'au déploiement de maximus-api#85 (commentaire du 2026-10-03). Les deux textes des suites de licence (contrôle à la saisie, retrait de la clé) y sont postés depuis le 2026-10-05 (commentaire 19724). **Vérifications manuelles sur la release publiée, toutes faites** : #365 le 2026-10-03, #346 et #350 le 2026-10-04 (voir Décisions). Reste l'étape 10 de `/release` sous Windows (Michel).
|
- **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop qu'attendait le go-live est publiée (v0.16.0, 2026-10-03).** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. **Volet serveur clos le 2026-10-03** (derniers commentaires relus le 2026-10-04) : maximus-api#62 fermée, tous critères cochés (maximus-api 0.4.2 en prod, secrets en runtime seul), avec la milestone `spec-paiements` de maximus-api (12/12) ; la-compagnie-maximus#150 (Link dans les textes légaux) livrée et fermée le 2026-10-01. **Clôture du go-live = la-compagnie-maximus#141** (`status:blocked`), qui n'attend plus que ce dépôt : l'updater vérifié (Free refuse et invite à acheter, Base accepte), #350 étant fermée depuis le 2026-10-04. la-compagnie-maximus#184 (politique de confidentialité : données d'activation, contrôle au démarrage) est en `status:review` ; sa PR reste en brouillon jusqu'au déploiement de maximus-api#85 (commentaire du 2026-10-03). Les deux textes des suites de licence (contrôle à la saisie, retrait de la clé) y sont postés depuis le 2026-10-05 (commentaire 19724). **Vérifications manuelles sur la release publiée, toutes faites** : #365 le 2026-10-03, #346 et #350 le 2026-10-04 (voir Décisions). Reste l'étape 10 de `/release` sous Windows (Michel).
|
||||||
- **Vérifications post-merge de #390** (PR #394, sur le prochain build) : la case 5 se coche après un classement manuel ; sous Linux, un vrai profil secondaire ancien affiche « database updated with versions … » au Journal, sa copie est dans `~/.config/com.simpl.resultat/migration-backups/` et l'import y fonctionne ; supprimer un profil retire son `.db` ; les rendements du Bilan s'affichent. #390 reste en `status:blocked` jusque-là.
|
- **Vérifications post-merge de #390** (PR #394, sur le prochain build) : la case 5 se coche après un classement manuel ; sous Linux, un vrai profil secondaire ancien affiche « database updated with versions … » au Journal, sa copie est dans `~/.config/com.simpl.resultat/migration-backups/` et l'import y fonctionne ; supprimer un profil retire son `.db` ; les rendements du Bilan s'affichent. #390 reste en `status:blocked` jusque-là.
|
||||||
- **Vérifications manuelles dues sur la v0.17.0 publiée** (onboarding + suites de licence ; Linux puis Windows), dans un ordre qui ne détruit le jeton d'activation qu'à la fin de ce qui en dépend : (1) #370, machine activée hors ligne → « Retirer la clé » : seconde confirmation qui donne la raison, Annuler ne change rien, sauts de ligne des fenêtres ; (2) #367, s'il existe un second poste avec la même clé : y désactiver ce poste, puis ouvrir ici la liste des machines → « Activer » revient sans recharger, ligne au Journal ; (3) #370 en ligne → place libérée, carte en Gratuite ; (4) #373 et #370 avec la clé révoquée du 2026-09-30 : Base une à deux secondes puis Gratuite, ligne « License check: the license server says the key was revoked » ; retirée → aucune désactivation envoyée, puis au redémarrage « no valid key stored, nothing sent » ; ressaisie → reste en Gratuite ; (5) clé testeur Premium ressaisie, « Activer », `verif-350.py` rend `64 brut: False dérivé: True` ; (6) #367, dossier v0.15.0 (clé A à côté du jeton d'une licence B révoquée) : couvert par un test de bout en bout, à faire seulement si un tel dossier existe encore. Plus la liste QA de l'onboarding (`docs/qa-onboarding.html`).
|
- **Vérifications manuelles dues sur la v0.17.0 publiée** (onboarding + suites de licence ; Linux puis Windows), dans un ordre qui ne détruit le jeton d'activation qu'à la fin de ce qui en dépend : (1) #370, machine activée hors ligne → « Retirer la clé » : seconde confirmation qui donne la raison, Annuler ne change rien, sauts de ligne des fenêtres ; (2) #367, s'il existe un second poste avec la même clé : y désactiver ce poste, puis ouvrir ici la liste des machines → « Activer » revient sans recharger, ligne au Journal ; (3) #370 en ligne → place libérée, carte en Gratuite ; (4) #373 et #370 avec la clé révoquée du 2026-09-30 : Base une à deux secondes puis Gratuite, ligne « License check: the license server says the key was revoked » ; retirée → aucune désactivation envoyée, puis au redémarrage « no valid key stored, nothing sent » ; ressaisie → reste en Gratuite ; (5) clé testeur Premium ressaisie, « Activer », `verif-350.py` rend `64 brut: False dérivé: True` ; (6) #367, dossier v0.15.0 (clé A à côté du jeton d'une licence B révoquée) : couvert par un test de bout en bout, à faire seulement si un tel dossier existe encore. Plus la liste QA de l'onboarding (`docs/qa-onboarding.html`).
|
||||||
|
- **Vérifications manuelles dues sur la prochaine release : parcours de démarrage guidé** (#396 à #401, milestone `planned-2026-10-06-onboarding-parcours-guide`), d'abord sous Linux avec Orca, puis sous Windows avec NVDA : les sections « Parcours guidé » de `docs/qa-onboarding.html` (profil neuf de bout en bout, bandeau et liste, « Passer » et « Reprendre », règle D9 sur un vrai profil ancien, édition Gratuite à 5/5, contour dans WebKitGTK et WebView2, mouvement réduit, clavier, annonces). #401 se ferme au merge ; la liste de la phase 1 pour la v0.17.0 reste lisible par `git show v0.17.0:docs/qa-onboarding.html`.
|
||||||
- Backlog ouvert : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). **#365 fermée le 2026-10-03** après sa vérification manuelle. **Milestone `planned-2026-10-03-suites-licence` fermée 3/3 le 2026-10-05** (#373, #370, #367 mergées, voir Décisions ; suivi #389 en `status:triage`). Hors milestone, en `status:ready` : **#369** (réserver le fractionnement de transactions à l'édition Base) et **#372** (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes). **#350 fermée le 2026-10-04** (étape 3 vérifiée en production, voir Décisions) ; reste de #363 (plafond de `/issue-license` à 36525 jours et essai d'une vraie clé de 100 ans, débloqués par le déploiement de maximus-api#63) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01 : maximus-api#82 (`status:triage` : identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (`status:review`, voir ci-dessus) ; clôture du go-live = la-compagnie-maximus#141 seule, maximus-api#62 étant fermée ; `spec-paiements` reste le parapluie des trois dépôts (ici 4/5, reste #363) ; onboarding phase 1 **livrée** : milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 le 2026-10-04 (#351 à #357, mergées en avance rapide, `667e058`) ; suivis ouverts : **#384** (`status:ready`, suivis non bloquants des revues) et **#371** (`status:blocked`, hors `/autopilot` : en-têtes CSV BMO à fournir par Max depuis un compte réel, un par type de compte, chèques/épargne et Mastercard, avec des valeurs fictives) ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **fermée 4/4** le 2026-10-05 (#232 livrée le 2026-07-27 ; #234 close le 2026-10-05 avec ses quatre preuves : dispatch run 464, PR #386 run 480, run planifié 488, `actcache` à 999 Mo) ; **#376** (`status:triage` : Node 20, en fin de vie depuis le 2026-04-30, encore installé par `check-frontend.yml` et `audit.yml`, et commentaire `concurrency` de `check-rust.yml` démenti par `forgejo-ops.md` pour les runs de PR). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
|
- Backlog ouvert : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). **#365 fermée le 2026-10-03** après sa vérification manuelle. **Milestone `planned-2026-10-03-suites-licence` fermée 3/3 le 2026-10-05** (#373, #370, #367 mergées, voir Décisions ; suivi #389 en `status:triage`). Hors milestone, en `status:ready` : **#369** (réserver le fractionnement de transactions à l'édition Base) et **#372** (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes). **#350 fermée le 2026-10-04** (étape 3 vérifiée en production, voir Décisions) ; reste de #363 (plafond de `/issue-license` à 36525 jours et essai d'une vraie clé de 100 ans, débloqués par le déploiement de maximus-api#63) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01 : maximus-api#82 (`status:triage` : identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (`status:review`, voir ci-dessus) ; clôture du go-live = la-compagnie-maximus#141 seule, maximus-api#62 étant fermée ; `spec-paiements` reste le parapluie des trois dépôts (ici 4/5, reste #363) ; onboarding phase 1 **livrée** : milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 le 2026-10-04 (#351 à #357, mergées en avance rapide, `667e058`) ; suivis ouverts : **#384** (`status:ready`, suivis non bloquants des revues) et **#371** (`status:blocked`, hors `/autopilot` : en-têtes CSV BMO à fournir par Max depuis un compte réel, un par type de compte, chèques/épargne et Mastercard, avec des valeurs fictives) ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **fermée 4/4** le 2026-10-05 (#232 livrée le 2026-07-27 ; #234 close le 2026-10-05 avec ses quatre preuves : dispatch run 464, PR #386 run 480, run planifié 488, `actcache` à 999 Mo) ; **#376** (`status:triage` : Node 20, en fin de vie depuis le 2026-04-30, encore installé par `check-frontend.yml` et `audit.yml`, et commentaire `concurrency` de `check-rust.yml` démenti par `forgejo-ops.md` pour les runs de PR). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
|
||||||
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. Restent **#317** (prémisse caduque depuis le 2026-09-26 : `react-router-dom` 7.18.2 et suivantes corrigent l'advisory par un bump de patch dans `^7.18.1`, sans migration v8 ; absorbée par #358, dont la PR la fermera), **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications) et **#345** (domicilier la contrainte `windows-sys 0.61.2` — analysée le 09-04 : le corps d'origine, écrit de mémoire en fin de revue, se trompait sur trois points. Le déclencheur n'est pas « n'importe quel `cargo update` » mais tout bump atteignant le sous-arbre réseau/TLS — mesuré : `-p anyhow` ne bouge rien, `-p hyper` fait descendre les 7 — soit précisément là où tombent les advisories. **Prémisse restreinte le 2026-09-21 par #348** : `-p rustls`, au cœur de ce même sous-arbre, n'en déplace **aucun** — le déclencheur tient à `hyper` et à ce qu'il tire, pas à la couche TLS en général, donc « sous-arbre réseau/TLS » prédit une dérive là où il n'y en a pas et ferait écrire une strophe à la main sans nécessité. D'où la suggestion portée en commentaire sur l'issue : formuler le garde-fou sur la **version résolue de `windows-sys`** plutôt que sur une liste de crates déclencheurs — la liste est ce qui vient d'être trouvé faux, la version est ce qui est protégé. #310 avait **bien** vu la dérive et l'avait jugée inoffensive *sur Linux*, où elle l'est ; son coût tombe sur le build Windows que personne ne mesurait : **3 versions de `windows-sys` compilées contre 5**. Aucun risque de correction — les 7 crates déclarent des bornes larges couvrant les deux états. Et cargo annonce « Locking 1 package » en écrivant 8 changements, donc le silence est le sien. Décision : garde-fou CI sur la version + ADR portant la condition de sortie. Le check croisé Windows depuis Linux a été **testé et écarté** — `cc-rs` s'arrête sur `lib.exe`, il faudrait `cargo-xwin`). Côté npm, **#358** ouverte le 2026-09-26 par `/analyse-vulnerabilite` : `npm audit` remonte 7 findings (4 high, 3 moderate) sur 4 dépendances racines, contre les 2 acceptés au 07-27, et chaque correctif tient dans la borne déjà déclarée ; le lock se régénère avec `npx npm@11` (npm 10 plante sur un bump de vitest 4.1.x).
|
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. Restent **#317** (prémisse caduque depuis le 2026-09-26 : `react-router-dom` 7.18.2 et suivantes corrigent l'advisory par un bump de patch dans `^7.18.1`, sans migration v8 ; absorbée par #358, dont la PR la fermera), **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications) et **#345** (domicilier la contrainte `windows-sys 0.61.2` — analysée le 09-04 : le corps d'origine, écrit de mémoire en fin de revue, se trompait sur trois points. Le déclencheur n'est pas « n'importe quel `cargo update` » mais tout bump atteignant le sous-arbre réseau/TLS — mesuré : `-p anyhow` ne bouge rien, `-p hyper` fait descendre les 7 — soit précisément là où tombent les advisories. **Prémisse restreinte le 2026-09-21 par #348** : `-p rustls`, au cœur de ce même sous-arbre, n'en déplace **aucun** — le déclencheur tient à `hyper` et à ce qu'il tire, pas à la couche TLS en général, donc « sous-arbre réseau/TLS » prédit une dérive là où il n'y en a pas et ferait écrire une strophe à la main sans nécessité. D'où la suggestion portée en commentaire sur l'issue : formuler le garde-fou sur la **version résolue de `windows-sys`** plutôt que sur une liste de crates déclencheurs — la liste est ce qui vient d'être trouvé faux, la version est ce qui est protégé. #310 avait **bien** vu la dérive et l'avait jugée inoffensive *sur Linux*, où elle l'est ; son coût tombe sur le build Windows que personne ne mesurait : **3 versions de `windows-sys` compilées contre 5**. Aucun risque de correction — les 7 crates déclarent des bornes larges couvrant les deux états. Et cargo annonce « Locking 1 package » en écrivant 8 changements, donc le silence est le sien. Décision : garde-fou CI sur la version + ADR portant la condition de sortie. Le check croisé Windows depuis Linux a été **testé et écarté** — `cc-rs` s'arrête sur `lib.exe`, il faudrait `cargo-xwin`). Côté npm, **#358** ouverte le 2026-09-26 par `/analyse-vulnerabilite` : `npm audit` remonte 7 findings (4 high, 3 moderate) sur 4 dépendances racines, contre les 2 acceptés au 07-27, et chaque correctif tient dans la borne déjà déclarée ; le lock se régénère avec `npx npm@11` (npm 10 plante sur un bump de vitest 4.1.x).
|
||||||
- Nouveau bug ouvert : **#320** — les installations `.rpm` ne reçoivent aucune mise à jour (`latest.json` ne porte qu'une entrée `linux-x86_64` construite depuis le `.deb`, `release.yml:104-122`), alors que les `.rpm` continuent d'être publiés en assets. Documenté en §3 de `docs/qa-update-cycle.md` (« ne pas tester : cassé, pas seulement non vérifié »).
|
- Nouveau bug ouvert : **#320** — les installations `.rpm` ne reçoivent aucune mise à jour (`latest.json` ne porte qu'une entrée `linux-x86_64` construite depuis le `.deb`, `release.yml:104-122`), alors que les `.rpm` continuent d'être publiés en assets. Documenté en §3 de `docs/qa-update-cycle.md` (« ne pas tester : cassé, pas seulement non vérifié »).
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ Cette liste ne doit pas rétrécir. Sans `insertBatch` ni `updateFileStatus`, la
|
||||||
Ne notifient pas :
|
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 ;
|
- `updateSource`, qui ne change aucun compte ; les notes d'une transaction ; les modèles de budget ; toute écriture d'une table que ne lit aucun compteur ;
|
||||||
|
- `categoryMigrationService.applyMigration`, qui réécrit `budget_entries` (`DELETE` des mois en double sous la catégorie cible, puis `UPDATE` de la catégorie) : il ne tourne que sur sa propre page, la migration des catégories, et la relecture qui suit tout changement de route rattrape le compteur ;
|
||||||
|
- `transactionService.deleteTransaction` (`DELETE FROM transactions`), sans appelant aujourd'hui : son premier appelant l'ajoute au tableau, avec son test ;
|
||||||
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, et `initializeGuidedPath`, la règle D9). Le fournisseur en est le seul écrivain et met son état à jour lui-même.
|
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, 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é.
|
**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é.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ simpl-resultat/
|
||||||
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
||||||
│ ├── services/ # 26 services métier (20 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/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
|
||||||
│ ├── i18n/ # Config i18next + locales FR/EN
|
│ ├── i18n/ # Config i18next + locales FR/EN
|
||||||
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
|
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
|
||||||
│ ├── App.tsx # Router principal
|
│ ├── App.tsx # Router principal
|
||||||
|
|
@ -97,7 +97,7 @@ simpl-resultat/
|
||||||
| `budget_templates` | Modèles de budget réutilisables |
|
| `budget_templates` | Modèles de budget réutilisables |
|
||||||
| `budget_template_entries` | Catégories et montants dans les modèles |
|
| `budget_template_entries` | Catégories et montants dans les modèles |
|
||||||
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
||||||
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand la liste de démarrage a été masquée ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352)… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
|
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand le parcours de démarrage est masqué ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352, #397), `onboarding_skipped_<clé>` (« 1 » par étape passée, #396), `onboarding_guided_initialized` (« 1 » une fois la règle D9 appliquée au profil, #396) — voir la section « Onboarding »… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
|
||||||
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
|
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
|
||||||
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
|
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
|
||||||
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
|
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
|
||||||
|
|
@ -306,9 +306,9 @@ L'étape `file-preview`, déclarée depuis l'origine dans `ImportWizardStep` san
|
||||||
|
|
||||||
Deux relevés **inventés** (`exemple-desjardins.csv` : compte chèque Desjardins ; `exemple-tangerine.csv` : carte de crédit Tangerine ; 41 lignes chacun), avec les jetons `{{M1}}` / `{{M2}}` que la commande Rust `create_sample_import_folders` remplace par les deux mois complets précédant le clic (`MM/YYYY`). Les fichiers sont embarqués dans le binaire par `include_str!` : le dépôt est la seule source, il n'y a rien à copier au build. Le module est **séparé** de `csv/index.ts` à dessein — ce corpus est figé à quinze cas par `csvAutoDetect.test.ts` et six boucles y passent chaque fixture ; y ajouter les gabarits élargirait le contrat figé en silence. `src/__integration__/onboarding-samples.test.ts` lit les **mêmes** fichiers et fige ce que l'assistant en fera : chaque fichier reconnu par la signature de la banque qu'il imite, toutes les lignes lues, aucune ligne en erreur, totaux signés attendus, et la part catégorisée par les 180 mots-clés du seed — en laissant exprès des lignes à classer à la main (dont « GARDERIE LES PETITS PAS »), pour que la cinquième case de la liste de démarrage ait quelque chose à cocher (#354).
|
Deux relevés **inventés** (`exemple-desjardins.csv` : compte chèque Desjardins ; `exemple-tangerine.csv` : carte de crédit Tangerine ; 41 lignes chacun), avec les jetons `{{M1}}` / `{{M2}}` que la commande Rust `create_sample_import_folders` remplace par les deux mois complets précédant le clic (`MM/YYYY`). Les fichiers sont embarqués dans le binaire par `include_str!` : le dépôt est la seule source, il n'y a rien à copier au build. Le module est **séparé** de `csv/index.ts` à dessein — ce corpus est figé à quinze cas par `csvAutoDetect.test.ts` et six boucles y passent chaque fixture ; y ajouter les gabarits élargirait le contrat figé en silence. `src/__integration__/onboarding-samples.test.ts` lit les **mêmes** fichiers et fige ce que l'assistant en fera : chaque fichier reconnu par la signature de la banque qu'il imite, toutes les lignes lues, aucune ligne en erreur, totaux signés attendus, et la part catégorisée par les 180 mots-clés du seed — en laissant exprès des lignes à classer à la main (dont « GARDERIE LES PETITS PAS »), pour que la cinquième case de la liste de démarrage ait quelque chose à cocher (#354).
|
||||||
|
|
||||||
## Onboarding — états vides et liste de démarrage
|
## Onboarding — états vides et parcours de démarrage
|
||||||
|
|
||||||
Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`). Aucune migration ni table : tout se lit dans les données existantes, et une seule préférence est écrite.
|
Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`) : les états vides, la liste de démarrage et le dossier d'exemple. Phase 2 (#396-#401) : le parcours guidé, un bandeau en haut de chaque page et une carte d'étape sur la page de l'étape en cours (voir « Parcours guidé », plus bas). Aucune migration ni table : tout se lit dans les données existantes, et le parcours n'écrit que des préférences par profil.
|
||||||
|
|
||||||
**Les quatre états vides** rendent le composant partagé `EmptyState` (`src/components/shared/` : icône, titre, description, action primaire en `<Link>` ou `<button>`, action secondaire optionnelle, enfants libres). Leur signal est le **compte brut** de transactions du profil, jamais le résultat d'un filtre — un profil filtré jusqu'à zéro ligne garde sa page normale :
|
**Les quatre états vides** rendent le composant partagé `EmptyState` (`src/components/shared/` : icône, titre, description, action primaire en `<Link>` ou `<button>`, action secondaire optionnelle, enfants libres). Leur signal est le **compte brut** de transactions du profil, jamais le résultat d'un filtre — un profil filtré jusqu'à zéro ligne garde sa page normale :
|
||||||
|
|
||||||
|
|
@ -319,20 +319,59 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
||||||
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||||
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||||
|
|
||||||
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a six lignes dans un ordre fixe. Chaque case est cochée par une **donnée**, jamais par un clic ; la source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed })` :
|
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a six lignes dans un ordre fixe. Chaque case est cochée par une **donnée**, jamais par un clic ; la source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped })` (`utils/onboardingChecklist.ts`, pur) :
|
||||||
|
|
||||||
| Case | Source de vérité |
|
| Case | Source de vérité |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Créer un profil | toujours cochée — la liste n'existe que dans un profil ouvert |
|
| 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 |
|
| Choisir un dossier d'import | préférence `import_folder` non vide |
|
||||||
| Configurer une source d'import | `COUNT(*) FROM import_sources` ≥ 1 |
|
| 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 |
|
| Classer une transaction à la main | `COUNT(*) FROM transactions WHERE is_manually_categorized = 1` ≥ 1 — ni « règle créée » ni « transactions catégorisées » : le seed IPC pose 180 mots-clés dans tout profil neuf et le wizard catégorise pendant l'import, ces deux cases seraient donc cochées d'avance |
|
||||||
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (5 au lieu de 6) et renvoyée vers `/settings/users` |
|
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (5 au lieu de 6) et renvoyée vers `/settings/users` |
|
||||||
|
|
||||||
`computeChecklist` rend `{ items, done, total, complete }` ; quand `complete`, le bandeau passe au message de fin (« Ouvrir le guide », « Fermer »). « Masquer » écrit `onboarding_checklist_hidden = "1"` dans `user_preferences` — **par profil**, donc détruite avec lui ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)) et sans effet sur les autres profils. Paramètres → Utilisateurs lit `hidden` dans `useOnboarding()` et montre « Afficher la liste de démarrage » tant que le parcours est masqué, y compris quand le bandeau de cette page vient de le masquer ; le clic passe par `show()` du fournisseur, seul écrivain de la préférence, et une confirmation remplace le bouton (#397).
|
`computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance.
|
||||||
|
|
||||||
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; le bandeau tombe au changement de dossier. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
|
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
|
||||||
|
|
||||||
|
### Parcours guidé
|
||||||
|
|
||||||
|
**Le fournisseur.** `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`), monté dans `AppShell` autour du bandeau et de `<Outlet/>`, appelle une seule fois `useOnboardingChecklist` : l'état garde son `useReducer` de domaine ([ADR 0002](adr/0002-useReducer-vs-redux.md)) et toute l'application le partage. Les pages et les composants le lisent par `useOnboarding()`, qui lève hors du fournisseur : `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `nextStep`, `mode`, `cardPage`, et les actions `hide()`, `show()`, `skip(clé)`, `resume(clé)`, `refresh()`. Le fournisseur survit à la navigation ; un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`), donc un fournisseur neuf, avec son propre chargeur. Il relit son état au montage, à chaque changement de route et après les écritures annoncées sur le canal de données. Ses chargements sont numérotés et seule la réponse du dernier lancé s'applique ; une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`) prime sur le snapshot d'un chargement parti avant elle ; une relecture qui échoue après un premier succès garde l'état affiché.
|
||||||
|
|
||||||
|
**Le canal de données.** `src/services/dataEvents.ts` ([ADR 0022](adr/0022-canal-evenements-donnees.md)) : chaque écriture qui peut changer un compteur du parcours appelle `notifyDataChanged()` une fois réussie (la liste des écritures et la règle d'ajout sont dans l'ADR), et le fournisseur, seul abonné, relit son état 150 ms après la dernière écriture d'une rafale (`followDataChanges`). C'est ce qui coche une étape pendant que sa page est ouverte.
|
||||||
|
|
||||||
|
**Les préférences** (`user_preferences`, par profil, donc détruites avec lui et sans effet sur les autres profils, [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Le fournisseur en est le seul écrivain, et aucune de ces écritures ne passe par le canal : il met son état à jour lui-même, une fois l'écriture faite.
|
||||||
|
|
||||||
|
| Clé | Valeur | Écrite par |
|
||||||
|
|---|---|---|
|
||||||
|
| `onboarding_checklist_hidden` | `"1"` quand le parcours est masqué ; un seul état pour le bandeau et la liste. Supprimée au réaffichage | `hideChecklist` (« Masquer », et « Fermer » à la fin du parcours) et la règle D9 ; `showChecklist` la supprime |
|
||||||
|
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des six clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
|
||||||
|
| `onboarding_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). 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) :
|
||||||
|
|
||||||
|
- `nextStep` : la première ligne déverrouillée ni faite ni passée ;
|
||||||
|
- `CARD_PAGES` (`/import`, `/transactions`, `/budget`) et `stepsOfPage` : les pages qui portent une carte d'étape, et les étapes que chacune guide ;
|
||||||
|
- `latchCardPage` : le verrou de carte, décrit ci-dessous ;
|
||||||
|
- `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 ; « 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.
|
||||||
|
|
||||||
|
**Le bandeau.** `OnboardingBanner` (`src/components/onboarding/`, piloté par props) est rendu par `AppShell` au-dessus de chaque page, dans le flux de la page, avec `key={pathname}` : chaque page commence liste repliée. En mode `banner` : « Démarrage n/N », qui attend `budgetReady`, « Étape suivante » avec le lien de l'étape, « Voir la liste » / « Fermer la liste » (`aria-expanded`, `aria-controls`) et « Masquer ». En mode `complete` : le message de fin, « Ouvrir le guide » (`/settings/users#gettingStarted`) et « Fermer », qui masque le parcours. Dans les modes `none` et `card`, il ne rend rien.
|
||||||
|
|
||||||
|
**Le verrou de carte.** `latchCardPage(previous, { pathname, nextStep, hidden })` rend la page qui porte la carte, ou null. Il est posé quand la route courante est celle de l'étape suivante et figure dans `CARD_PAGES`. Il tient quand l'étape se coche : la carte reste pour annoncer « Fait. » et proposer la suite, même quand l'étape suivante vit sur une autre page. Il est relâché au changement de route, quand le parcours est masqué, et par « Passer » (`SKIP`) ; le rendu suivant le repose si la nouvelle étape suivante est sur la même page. La fonction est idempotente : le fournisseur la dérive pendant le rendu et range le résultat dans son reducer (`SET_CARD_PAGE`), ce qui évite l'image de bandeau qu'un effet laisserait passer sur une page à carte.
|
||||||
|
|
||||||
|
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « Passer cette étape » tant que l'étape n'est pas faite ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
|
||||||
|
|
||||||
|
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, et sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, une seule cible à la fois. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
|
||||||
|
|
||||||
|
**Paramètres → Utilisateurs** lit `hidden` dans `useOnboarding()` et montre « Afficher la liste de démarrage » tant que le parcours est masqué, y compris quand le bandeau de cette même page vient de le masquer. Le clic passe par `show()` du fournisseur, et une confirmation remplace le bouton (#397).
|
||||||
|
|
||||||
## Gating par édition (licence)
|
## Gating par édition (licence)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,11 @@ Simpl'Résultat vous aide à suivre vos finances personnelles en important des r
|
||||||
- Profils multiples avec bases de données séparées et NIP optionnel
|
- Profils multiples avec bases de données séparées et NIP optionnel
|
||||||
- Mode sombre avec palette gris chaud
|
- Mode sombre avec palette gris chaud
|
||||||
- Export et import de données avec chiffrement AES-256 optionnel
|
- Export et import de données avec chiffrement AES-256 optionnel
|
||||||
|
- Parcours de démarrage guidé : un bandeau en haut de chaque page et, sur la page de l'étape en cours, une carte qui dit quoi faire
|
||||||
|
|
||||||
### Démarrage rapide
|
### Démarrage rapide
|
||||||
|
|
||||||
Les six étapes ci-dessous sont celles de la liste de démarrage affichée sur le tableau de bord d'un profil neuf ; chaque case se coche d'elle-même quand l'étape est faite.
|
Les six étapes ci-dessous sont celles du parcours de démarrage. La carte d'accueil du tableau de bord d'un profil neuf les liste, puis le bandeau en haut de chaque page mène de l'une à la suivante (voir « Le parcours de démarrage », plus bas) ; chaque étape se coche d'elle-même quand elle est faite.
|
||||||
|
|
||||||
1. Créer un profil — chaque profil a sa propre base de données
|
1. Créer un profil — chaque profil a sa propre base de données
|
||||||
2. Choisir un dossier d'import — le dossier où vous déposerez vos relevés, un sous-dossier par compte bancaire
|
2. Choisir un dossier d'import — le dossier où vous déposerez vos relevés, un sous-dossier par compte bancaire
|
||||||
|
|
@ -28,6 +29,20 @@ Les six étapes ci-dessous sont celles de la liste de démarrage affichée sur l
|
||||||
5. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
|
5. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
|
||||||
6. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
|
6. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
|
||||||
|
|
||||||
|
### Le parcours de démarrage
|
||||||
|
|
||||||
|
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/6 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste
|
||||||
|
2. « Voir la liste » déplie les six étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
|
||||||
|
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é ; 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 : « 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
|
### Astuces
|
||||||
|
|
||||||
- Vous pouvez basculer entre le français et l'anglais via le sélecteur de langue dans la barre latérale
|
- Vous pouvez basculer entre le français et l'anglais via le sélecteur de langue dans la barre latérale
|
||||||
|
|
@ -63,6 +78,7 @@ Gérez plusieurs profils indépendants, chacun avec sa propre base de données.
|
||||||
- La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible
|
- La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible
|
||||||
- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). La copie est supprimée avec le profil
|
- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). La copie est supprimée avec le profil
|
||||||
- Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (`~/.config/com.simpl.resultat/profile_….db`) ; vous pouvez supprimer ce fichier à la main
|
- Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (`~/.config/com.simpl.resultat/profile_….db`) ; vous pouvez supprimer ce fichier à la main
|
||||||
|
- Le parcours de démarrage est propre à chaque profil : ses étapes se cochent sur les données du profil, et le masquer dans un profil ne le masque pas dans les autres
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -78,13 +94,13 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
|
||||||
- Histogramme empilé des dépenses par catégorie et par mois
|
- Histogramme empilé des dépenses par catégorie et par mois
|
||||||
- Sélecteur de période ajustable (par défaut : année à ce jour)
|
- Sélecteur de période ajustable (par défaut : année à ce jour)
|
||||||
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
||||||
- Liste de démarrage pour un profil neuf : six cases qui se cochent d'elles-mêmes selon vos données, avec un bouton « Masquer »
|
- Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : six étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »
|
||||||
|
|
||||||
### Comment faire
|
### Comment faire
|
||||||
|
|
||||||
1. Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total
|
1. Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total
|
||||||
2. Après votre premier import, la liste se réduit à une barre compacte au-dessus des filtres, avec son compteur (par exemple 4 / 6), et disparaît d'elle-même quand toutes les cases sont cochées
|
2. Dès que le profil a des transactions, la liste quitte le tableau de bord : le bandeau de démarrage, en haut de chaque page, la remplace jusqu'à la fin du parcours (voir Premiers pas → Le parcours de démarrage)
|
||||||
3. « Masquer » retire la liste de ce profil seulement ; vous pouvez la réafficher depuis Paramètres → Utilisateurs
|
3. « Masquer » retire la liste de la carte d'accueil, et le bandeau de toutes les pages, pour ce profil seulement ; Paramètres → Utilisateurs les réaffiche
|
||||||
4. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
|
4. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
|
||||||
5. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
|
5. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
|
||||||
6. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
|
6. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
|
||||||
|
|
@ -567,7 +583,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
||||||
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
|
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
|
||||||
- Import des données depuis un fichier exporté précédemment
|
- Import des données depuis un fichier exporté précédemment
|
||||||
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés
|
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés
|
||||||
- Réaffichage de la liste de démarrage du tableau de bord (Paramètres → Utilisateurs) quand elle a été masquée
|
- Réaffichage du parcours de démarrage (Paramètres → Utilisateurs) quand il a été masqué
|
||||||
|
|
||||||
### Comment faire
|
### Comment faire
|
||||||
|
|
||||||
|
|
@ -578,7 +594,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
||||||
5. Utilisez la section Gestion des données pour exporter ou importer vos données
|
5. Utilisez la section Gestion des données pour exporter ou importer vos données
|
||||||
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
||||||
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
|
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
|
||||||
8. Pour revoir la liste de démarrage après l'avoir masquée, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » ; elle réapparaît sur le tableau de bord de ce profil. Le bouton n'est visible que si la liste a été masquée
|
8. Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page
|
||||||
|
|
||||||
### Astuces
|
### Astuces
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,14 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>QA — Parcours d'accueil</title>
|
<title>QA — Parcours d'accueil</title>
|
||||||
<!--
|
<!--
|
||||||
Checklist de parcours pour la phase 1 de l'accueil (issues #351 à #357).
|
Checklist de parcours de l'accueil : phase 1 (issues #351 à #357) et parcours
|
||||||
Page statique, français seulement, aucune dépendance réseau : tout est inline.
|
guidé de la phase 2 (issues #396 à #401). Page statique, français seulement,
|
||||||
L'état (cases + commentaires) vit dans le localStorage du navigateur qui
|
aucune dépendance réseau : tout est inline. L'état (cases + commentaires) vit
|
||||||
ouvre le fichier ; « Copier le rapport » produit le texte brut à coller dans
|
dans le localStorage du navigateur qui ouvre le fichier, sous une clé propre à
|
||||||
Paramètres → Systèmes → Rétroaction.
|
cette version de la liste ; « Copier le rapport » produit le texte brut à
|
||||||
|
coller dans Paramètres → Système → Envoyer un feedback.
|
||||||
|
La liste de la phase 1 telle que publiée avec la v0.17.0 reste lisible par
|
||||||
|
`git show v0.17.0:docs/qa-onboarding.html`.
|
||||||
-->
|
-->
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -65,6 +68,7 @@
|
||||||
padding: 12px 16px; margin: 12px 0 20px;
|
padding: 12px 16px; margin: 12px 0 20px;
|
||||||
}
|
}
|
||||||
.prereq ul { margin: 6px 0 0; padding-left: 20px; }
|
.prereq ul { margin: 6px 0 0; padding-left: 20px; }
|
||||||
|
.intro { color: var(--muted); font-size: 0.92rem; }
|
||||||
ol.steps { list-style: none; margin: 0; padding: 0; counter-reset: step; }
|
ol.steps { list-style: none; margin: 0; padding: 0; counter-reset: step; }
|
||||||
ol.steps li {
|
ol.steps li {
|
||||||
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
|
@ -119,11 +123,11 @@
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>QA — Parcours d'accueil</h1>
|
<h1>QA — Parcours d'accueil</h1>
|
||||||
<p class="lead">Première ouverture de Simpl'Résultat jusqu'à la liste de démarrage complète. Coche chaque étape quand elle se passe comme décrit, et note où tu as hésité, même une seconde : c'est l'hésitation qu'on cherche, pas l'échec.</p>
|
<p class="lead">Première ouverture de Simpl'Résultat jusqu'à la fin du parcours de démarrage guidé. Coche chaque étape quand elle se passe comme décrit, et note où tu as hésité, même une seconde : c'est l'hésitation qu'on cherche, pas l'échec.</p>
|
||||||
|
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<label>Testeur <input type="text" id="tester" placeholder="prénom" autocomplete="off"></label>
|
<label>Testeur <input type="text" id="tester" placeholder="prénom" autocomplete="off"></label>
|
||||||
<label>Version <input type="text" id="version" placeholder="ex. 0.17.0" autocomplete="off"></label>
|
<label>Version <input type="text" id="version" placeholder="ex. 0.18.0" autocomplete="off"></label>
|
||||||
<label>Système <input type="text" id="os" placeholder="Windows 11, Ubuntu 24.04…" autocomplete="off"></label>
|
<label>Système <input type="text" id="os" placeholder="Windows 11, Ubuntu 24.04…" autocomplete="off"></label>
|
||||||
<span class="progress" id="progress"></span>
|
<span class="progress" id="progress"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -131,14 +135,18 @@
|
||||||
<section class="prereq">
|
<section class="prereq">
|
||||||
<strong>Avant de commencer</strong>
|
<strong>Avant de commencer</strong>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li>Une version publiée ou un build de release : les cas du parcours guidé vérifient le rendu réel du webview, WebKitGTK sous Linux et WebView2 sous Windows.</li>
|
||||||
<li>Une installation neuve, ou un profil neuf : la liste de démarrage n'apparaît que dans un profil sans transaction.</li>
|
<li>Une installation neuve, ou un profil neuf : la liste de démarrage n'apparaît que dans un profil sans transaction.</li>
|
||||||
<li>Pour l'étape Budget, une clé Base ou Premium ; sans clé, l'étape se présente verrouillée « Base et plus » et le total passe à 5 — note-le dans le champ, c'est attendu.</li>
|
<li>Pour l'étape Budget, une clé Base ou Premium ; sans clé, l'étape se présente verrouillée « Base et plus » et le total passe à 5 — note-le dans le champ, c'est attendu.</li>
|
||||||
|
<li>Pour le cas de l'édition Gratuite, une session utilisateur ou une machine sans clé : ne retire pas la clé de ta machine habituelle, puisque retirer une clé efface aussi le jeton d'activation.</li>
|
||||||
|
<li>Pour la règle D9, un profil qui a des transactions, créé avec la v0.17.0 ou avant, et qu'aucune version du parcours guidé n'a encore ouvert (un build de développement de la pile compte).</li>
|
||||||
|
<li>Les cas d'accessibilité et de rendu se font sous Linux avec Orca, puis sous Windows avec NVDA.</li>
|
||||||
<li>La page garde tes réponses dans ce navigateur. Rien n'est envoyé : c'est toi qui colles le rapport dans l'application.</li>
|
<li>La page garde tes réponses dans ce navigateur. Rien n'est envoyé : c'est toi qui colles le rapport dans l'application.</li>
|
||||||
|
<li>Pour vérifier la v0.17.0 elle-même, prends la liste publiée avec elle : <code>git show v0.17.0:docs/qa-onboarding.html</code>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2>Étapes</h2>
|
<div id="sections"></div>
|
||||||
<ol class="steps" id="steps"></ol>
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="primary" id="copy">Copier le rapport</button>
|
<button type="button" class="primary" id="copy">Copier le rapport</button>
|
||||||
|
|
@ -147,7 +155,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fallback" id="fallback" hidden>
|
<div class="fallback" id="fallback" hidden>
|
||||||
<p>Le presse-papiers n'est pas accessible depuis ce fichier : sélectionne le texte ci-dessous (il est déjà sélectionné) et copie-le avec Ctrl+C, puis colle-le dans Paramètres → Systèmes → Rétroaction.</p>
|
<p>Le presse-papiers n'est pas accessible depuis ce fichier : sélectionne le texte ci-dessous (il est déjà sélectionné) et copie-le avec Ctrl+C, puis colle-le dans Paramètres → Système → Envoyer un feedback.</p>
|
||||||
<textarea id="report" readonly aria-label="Rapport à copier"></textarea>
|
<textarea id="report" readonly aria-label="Rapport à copier"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -158,10 +166,18 @@
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var STORAGE_KEY = "simpl-resultat.qa-onboarding.v1";
|
// v2: the guided path (#396 to #401) changed the list; the answers of the
|
||||||
|
// phase 1 list stay under their own key.
|
||||||
|
var STORAGE_KEY = "simpl-resultat.qa-onboarding.v2";
|
||||||
|
|
||||||
// One row per step of the issue. `expect` is what the tester should see.
|
// One group per part of the path, one row per case. `expect` is what the
|
||||||
var STEPS = [
|
// tester should see. Ids are unique across the groups.
|
||||||
|
var SECTIONS = [
|
||||||
|
{
|
||||||
|
id: "phase-1",
|
||||||
|
title: "Phase 1 : premier lancement, états vides et dossier d'exemple",
|
||||||
|
intro: "Les cas de la phase 1 (#351 à #357) qui restent valides avec le parcours guidé. Ils peuvent se faire dans la même passe que la section suivante, sur le même profil neuf.",
|
||||||
|
steps: [
|
||||||
{ id: "install", title: "Installer et lancer l'application",
|
{ id: "install", title: "Installer et lancer l'application",
|
||||||
expect: "L'installateur s'exécute sans avertissement bloquant et l'application s'ouvre sur la sélection de profil." },
|
expect: "L'installateur s'exécute sans avertissement bloquant et l'application s'ouvre sur la sélection de profil." },
|
||||||
{ id: "profile", title: "Créer un profil",
|
{ id: "profile", title: "Créer un profil",
|
||||||
|
|
@ -180,22 +196,113 @@
|
||||||
expect: "« Exemple Desjardins » et « Exemple Tangerine » sont listées comme sources, sans actualiser à la main." },
|
expect: "« Exemple Desjardins » et « Exemple Tangerine » sont listées comme sources, sans actualiser à la main." },
|
||||||
{ id: "import-desjardins", title: "Importer « Exemple Desjardins » : aperçu, signes, doublons, rapport",
|
{ id: "import-desjardins", title: "Importer « Exemple Desjardins » : aperçu, signes, doublons, rapport",
|
||||||
expect: "Le format est détecté (bandeau Desjardins), l'aperçu montre des sorties et des entrées dans le bon sens (pas besoin d'« Inverser les signes »), aucun doublon, puis le rapport d'import indique le nombre de transactions importées." },
|
expect: "Le format est détecté (bandeau Desjardins), l'aperçu montre des sorties et des entrées dans le bon sens (pas besoin d'« Inverser les signes »), aucun doublon, puis le rapport d'import indique le nombre de transactions importées." },
|
||||||
{ id: "dashboard-compact", title: "Retour au tableau de bord : liste compacte à 4 / 6",
|
|
||||||
expect: "Les graphiques sont là, la liste est devenue une barre compacte au-dessus des filtres et affiche 4 / 6 (ou 4 / 5 sans clé) : dossier, source et premier import sont cochés." },
|
|
||||||
{ id: "manual-category", title: "Transactions : classer « GARDERIE LES PETITS PAS » à la main",
|
{ id: "manual-category", title: "Transactions : classer « GARDERIE LES PETITS PAS » à la main",
|
||||||
expect: "La transaction est sans catégorie après l'import ; lui en donner une à la main la garde classée." },
|
expect: "La transaction est sans catégorie après l'import ; lui en donner une à la main la garde classée." },
|
||||||
{ id: "five-of-six", title: "Liste à 5 / 6",
|
|
||||||
expect: "De retour sur le tableau de bord, « Classer une transaction à la main » est cochée." },
|
|
||||||
{ id: "budget", title: "Budget (clé Base ou Premium) : saisir un montant",
|
{ id: "budget", title: "Budget (clé Base ou Premium) : saisir un montant",
|
||||||
expect: "La page Budget montre la grille (pas l'état vide, puisqu'il y a des transactions) ; un montant saisi dans une cellule est conservé." },
|
expect: "La page Budget montre la grille (pas l'état vide, puisqu'il y a des transactions) ; un montant saisi dans une cellule est conservé." },
|
||||||
{ id: "six-of-six", title: "Liste à 6 / 6, puis disparue",
|
|
||||||
expect: "Le tableau de bord affiche brièvement 6 / 6 ou plus aucune liste : une liste complète ne s'affiche plus." },
|
|
||||||
{ id: "hide-reshow", title: "« Masquer » sur un second profil, puis réaffichage depuis Paramètres",
|
{ id: "hide-reshow", title: "« Masquer » sur un second profil, puis réaffichage depuis Paramètres",
|
||||||
expect: "Créer un second profil (clé Base ou plus) : sa liste est à 1 / 6 ; « Masquer » la retire de ce profil seulement, le premier profil n'est pas touché ; Paramètres → Utilisateurs montre « Afficher la liste de démarrage », qui la ramène." },
|
expect: "Créer un second profil (clé Base ou plus) : sa carte d'accueil montre la liste à 1 / 6. « Masquer » retire la liste de ce profil seulement, ainsi que le bandeau de ses autres pages ; le premier profil n'est pas touché. Paramètres → Utilisateurs montre « Afficher la liste de démarrage », qui ramène le parcours." },
|
||||||
{ id: "guide-link", title: "Ouvrir le guide « Exporter un CSV depuis votre banque » depuis l'état vide d'Import",
|
{ id: "guide-link", title: "Ouvrir le guide « Exporter un CSV depuis votre banque » depuis l'état vide d'Import",
|
||||||
expect: "Sur un profil sans dossier ou sans source, le lien de l'état vide ouvre le guide directement sur la sous-section, avec les procédures par banque." }
|
expect: "Sur un profil sans dossier ou sans source, le lien de l'état vide ouvre le guide directement sur la sous-section, avec les procédures par banque." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "parcours-guide",
|
||||||
|
title: "Parcours guidé : un profil neuf de bout en bout",
|
||||||
|
intro: "Phase 2 (#396 à #401), sur un profil neuf en édition Base ou Premium. De l'étape 2 à l'étape 6, avance seulement par les liens d'étape (ligne de la liste, « Étape suivante »), sans revenir au tableau de bord : chaque page doit dire quoi faire.",
|
||||||
|
steps: [
|
||||||
|
{ id: "guided-dashboard", title: "Tableau de bord vide sans bandeau, autres pages avec bandeau",
|
||||||
|
expect: "Sur le tableau de bord, aucun bandeau de démarrage au-dessus du titre : la carte d'accueil porte déjà la liste. Ouvre Catégories, puis Rapports : chacune commence par le bandeau « Démarrage 1/6 · Étape suivante : Choisir un dossier d'import → », avec « Voir la liste » et « Masquer »." },
|
||||||
|
{ id: "guided-import-card", title: "Étape 2 : la carte Import, son contour et le focus",
|
||||||
|
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 6 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
|
||||||
|
{ id: "guided-import-folder", title: "Étape 3 : dossier choisi, puis dossier d'exemple",
|
||||||
|
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 6 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte de ta banque et entoure la première source." },
|
||||||
|
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
|
||||||
|
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → » ; après « Terminé », elle garde « Fait. »." },
|
||||||
|
{ id: "guided-transactions", title: "Étape 5 : Transactions, entrée guidée",
|
||||||
|
expect: "Clique « Étape suivante » sur la carte Import. La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 5 sur 6 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
|
||||||
|
{ id: "guided-budget", title: "Étape 6 : Budget, « Tout déplier » puis la cellule du mois",
|
||||||
|
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 6 sur 6 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. », puis le message de fin, avec « Ouvrir le guide » et « Fermer »." },
|
||||||
|
{ id: "guided-budget-tab", title: "Budget : Tab enregistre et passe au mois suivant",
|
||||||
|
expect: "Dans une cellule, saisis un montant puis Tab : le montant est enregistré et la saisie passe au mois suivant, comme le dit la carte." },
|
||||||
|
{ id: "guided-live", title: "Les étapes se cochent page ouverte",
|
||||||
|
expect: "Pendant les étapes 2 à 6, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
|
||||||
|
{ id: "guided-end", title: "Fin du parcours : bandeau de fin, « Ouvrir le guide » et « Fermer »",
|
||||||
|
expect: "Quitte la page Budget : en haut de chaque page, tableau de bord compris, le bandeau dit « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. ». « Ouvrir le guide » ouvre Paramètres → Utilisateurs sur la section « Premiers pas », qui décrit le parcours (« Le parcours de démarrage »). « Fermer » retire le bandeau de toutes les pages, et Paramètres → Utilisateurs propose alors « Afficher la liste de démarrage »." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "parcours-guide-liste",
|
||||||
|
title: "Parcours guidé : bandeau, liste, Passer et Reprendre",
|
||||||
|
intro: "Sur un profil où le parcours n'est pas terminé, par exemple un second profil neuf (clé Base ou plus).",
|
||||||
|
steps: [
|
||||||
|
{ id: "guided-list", title: "« Voir la liste » et « Fermer la liste »",
|
||||||
|
expect: "Sur une page sans carte (Catégories, par exemple), « Voir la liste » déplie les six étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
|
||||||
|
{ 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",
|
||||||
|
expect: "Sur Paramètres → Utilisateurs, clique « Masquer » dans le bandeau : le bandeau disparaît et, sans quitter la page, la ligne « Parcours de démarrage » apparaît avec « Afficher la liste de démarrage ». Un clic la remplace par « Le parcours de démarrage réapparaît en haut des pages », et le bandeau revient en haut de la page." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "parcours-guide-cas",
|
||||||
|
title: "Parcours guidé : cas particuliers et limites connues",
|
||||||
|
intro: "Chaque cas dit sur quel profil le faire. Les limites connues sont à constater et à juger : note si elles gênent.",
|
||||||
|
steps: [
|
||||||
|
{ id: "guided-d9", title: "Profil déjà actif masqué d'office, puis réaffiché (règle D9)",
|
||||||
|
expect: "Ouvre un profil qui avait des transactions avant cette version. Aucun bandeau ni carte, sur aucune page ; le Journal (Paramètres → Système) porte « Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again) ». Paramètres → Utilisateurs propose « Afficher la liste de démarrage » : un clic ramène le bandeau, avec un compteur fidèle aux données du profil. Rouvrir le profil ne le masque plus." },
|
||||||
|
{ id: "guided-free", title: "Édition Gratuite : 5 étapes, fin à 5/5",
|
||||||
|
expect: "Sur une session ou une machine sans clé, avec un profil neuf : le compteur dit 1/5, et la ligne Budget est verrouillée « Base et plus » et mène à la carte Licence. Après le classement d'une transaction, la carte Transactions (« Étape 5 sur 5 ») annonce « Fait. » puis le message de fin : aucune carte Budget, fin à 5/5." },
|
||||||
|
{ id: "guided-noop-import", title: "Un import qui n'ajoute rien",
|
||||||
|
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». Après « Terminé », la carte reste sur « Étape 4 sur 6 » (« 4 sur 5 » en édition Gratuite), entoure la première source et n'annonce toujours pas « Fait. » ; la liste laisse « Importer un premier fichier » à faire." },
|
||||||
|
{ 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",
|
||||||
|
expect: "Passe l'étape 5 depuis sa carte, change les filtres de la page Transactions, puis, sur cette même page, clique « Reprendre » dans la liste du bandeau. La carte revient, mais la page garde tes filtres au lieu de l'entrée guidée ; la phrase de la carte suit le filtre réellement affiché. À juger." },
|
||||||
|
{ 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 (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",
|
||||||
|
expect: "À l'ouverture de la page Import, la carte du dossier peut afficher un très bref instant « Aucun dossier configuré » avant la lecture du dossier ; l'état vide et la carte de démarrage, eux, attendent cette lecture. À juger." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "parcours-guide-a11y",
|
||||||
|
title: "Parcours guidé : accessibilité et rendu",
|
||||||
|
intro: "Sous Linux (WebKitGTK, lecteur d'écran Orca), puis sous Windows (WebView2, lecteur d'écran NVDA). Indique le système dans le champ prévu.",
|
||||||
|
steps: [
|
||||||
|
{ id: "guided-render-linux", title: "Contour et pulsation sous Linux (WebKitGTK)",
|
||||||
|
expect: "Un contour de couleur d'accent, d'environ 3 px et décalé de 2 px, entoure le contrôle désigné sur les trois pages ; la pulsation joue trois fois en un peu moins de 4 secondes, puis le contour reste fixe. Note s'il est rogné par la grille ou par un conteneur." },
|
||||||
|
{ id: "guided-render-windows", title: "Contour et pulsation sous Windows (WebView2)",
|
||||||
|
expect: "Même attendu que sous Linux : contour de couleur d'accent d'environ 3 px, décalé de 2 px, trois pulsations, puis un contour fixe, jamais rogné." },
|
||||||
|
{ id: "guided-reduced-motion", title: "Mouvement réduit (prefers-reduced-motion)",
|
||||||
|
expect: "Active l'option du système qui réduit les animations (GNOME : réduire les animations, dans Accessibilité ; Windows : effets d'animation désactivés, dans Accessibilité → Effets visuels), puis relance l'application si rien ne change : le contour s'affiche, sans aucune pulsation." },
|
||||||
|
{ id: "guided-contrast", title: "Contraste du contour, thème clair et thème sombre",
|
||||||
|
expect: "Dans les deux thèmes (bouton de la barre latérale), le contour se distingue nettement du fond de la page, des cartes et du contour de focus du clavier, sur les pages Import, Transactions et Budget." },
|
||||||
|
{ id: "guided-keyboard", title: "Clavier seul",
|
||||||
|
expect: "Sans souris, de l'étape 2 à l'étape 6 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Quand le contrôle entouré a le focus, l'anneau de focus du système remplace le contour. Le focus reste visible partout et n'est jamais pris au piège." },
|
||||||
|
{ 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)",
|
||||||
|
expect: "Mêmes annonces que sous Orca : région « Parcours de démarrage », état replié ou déplié de « Voir la liste », état de chaque étape de la liste, titre de la carte à l'arrivée par « Étape suivante », « Fait. » sans déplacement du focus, confirmation de Paramètres → Utilisateurs." }
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
var ALL_STEPS = [];
|
||||||
|
SECTIONS.forEach(function (section) {
|
||||||
|
section.steps.forEach(function (step) { ALL_STEPS.push(step); });
|
||||||
|
});
|
||||||
|
|
||||||
var state = load();
|
var state = load();
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
|
|
@ -231,16 +338,17 @@
|
||||||
document.getElementById("status").textContent = text;
|
document.getElementById("status").textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress() {
|
function countDone() {
|
||||||
var done = 0;
|
var done = 0;
|
||||||
STEPS.forEach(function (s) { if (stepState(s.id).done) done += 1; });
|
ALL_STEPS.forEach(function (s) { if (stepState(s.id).done) done += 1; });
|
||||||
document.getElementById("progress").textContent = done + " / " + STEPS.length + " étapes cochées";
|
return done;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function updateProgress() {
|
||||||
var list = document.getElementById("steps");
|
document.getElementById("progress").textContent = countDone() + " / " + ALL_STEPS.length + " étapes cochées";
|
||||||
list.textContent = "";
|
}
|
||||||
STEPS.forEach(function (step) {
|
|
||||||
|
function renderStep(step, list) {
|
||||||
var st = stepState(step.id);
|
var st = stepState(step.id);
|
||||||
var li = document.createElement("li");
|
var li = document.createElement("li");
|
||||||
li.className = st.done ? "done" : "";
|
li.className = st.done ? "done" : "";
|
||||||
|
|
@ -290,6 +398,30 @@
|
||||||
li.appendChild(expect);
|
li.appendChild(expect);
|
||||||
li.appendChild(where);
|
li.appendChild(where);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var root = document.getElementById("sections");
|
||||||
|
root.textContent = "";
|
||||||
|
SECTIONS.forEach(function (section) {
|
||||||
|
var block = document.createElement("section");
|
||||||
|
block.id = section.id;
|
||||||
|
|
||||||
|
var heading = document.createElement("h2");
|
||||||
|
heading.textContent = section.title;
|
||||||
|
block.appendChild(heading);
|
||||||
|
|
||||||
|
var intro = document.createElement("p");
|
||||||
|
intro.className = "intro";
|
||||||
|
intro.textContent = section.intro;
|
||||||
|
block.appendChild(intro);
|
||||||
|
|
||||||
|
var list = document.createElement("ol");
|
||||||
|
list.className = "steps";
|
||||||
|
section.steps.forEach(function (step) { renderStep(step, list); });
|
||||||
|
block.appendChild(list);
|
||||||
|
|
||||||
|
root.appendChild(block);
|
||||||
});
|
});
|
||||||
|
|
||||||
["tester", "version", "os"].forEach(function (key) {
|
["tester", "version", "os"].forEach(function (key) {
|
||||||
|
|
@ -310,20 +442,21 @@
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var date = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
|
var date = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
|
||||||
" " + pad(now.getHours()) + ":" + pad(now.getMinutes());
|
" " + pad(now.getHours()) + ":" + pad(now.getMinutes());
|
||||||
var done = 0;
|
|
||||||
var lines = [];
|
var lines = [];
|
||||||
lines.push("QA parcours d'accueil — " + date);
|
lines.push("QA parcours d'accueil — " + date);
|
||||||
if (state.meta.tester) lines.push("Testeur : " + state.meta.tester);
|
if (state.meta.tester) lines.push("Testeur : " + state.meta.tester);
|
||||||
if (state.meta.version) lines.push("Version : " + state.meta.version);
|
if (state.meta.version) lines.push("Version : " + state.meta.version);
|
||||||
if (state.meta.os) lines.push("Système : " + state.meta.os);
|
if (state.meta.os) lines.push("Système : " + state.meta.os);
|
||||||
|
lines.push("Étapes cochées : " + countDone() + " / " + ALL_STEPS.length);
|
||||||
|
SECTIONS.forEach(function (section) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
STEPS.forEach(function (step, i) {
|
lines.push("== " + section.title + " ==");
|
||||||
|
section.steps.forEach(function (step, i) {
|
||||||
var st = stepState(step.id);
|
var st = stepState(step.id);
|
||||||
if (st.done) done += 1;
|
|
||||||
lines.push((st.done ? "[x] " : "[ ] ") + (i + 1) + ". " + step.title);
|
lines.push((st.done ? "[x] " : "[ ] ") + (i + 1) + ". " + step.title);
|
||||||
if (st.note && st.note.trim()) lines.push(" Hésitation : " + st.note.trim());
|
if (st.note && st.note.trim()) lines.push(" Hésitation : " + st.note.trim());
|
||||||
});
|
});
|
||||||
lines.splice(lines.indexOf("") , 0, "Étapes cochées : " + done + " / " + STEPS.length);
|
});
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,7 +474,7 @@
|
||||||
document.getElementById("fallback").hidden = true;
|
document.getElementById("fallback").hidden = true;
|
||||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||||
navigator.clipboard.writeText(text).then(function () {
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
setStatus("Rapport copié. Colle-le dans Paramètres → Systèmes → Rétroaction.");
|
setStatus("Rapport copié. Colle-le dans Paramètres → Système → Envoyer un feedback.");
|
||||||
}, function () {
|
}, function () {
|
||||||
setStatus("Copie automatique refusée : utilise la zone ci-dessous.");
|
setStatus("Copie automatique refusée : utilise la zone ci-dessous.");
|
||||||
showFallback(text);
|
showFallback(text);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
// subtotal-position preference is read from a stubbed localStorage. What is
|
// 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
|
// pinned is where the one highlight lands: "Expand all" or this month's cell
|
||||||
// of the first editable row shown.
|
// 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 { renderToStaticMarkup } from "react-dom/server";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||||
|
|
@ -172,10 +172,24 @@ describe("BudgetTable — the getting-started card's target (#400)", () => {
|
||||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("the cell follows the clock's month", () => {
|
||||||
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
||||||
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("subtotals at the bottom: still the first editable row shown", () => {
|
it("subtotals at the bottom: still the first editable row shown", () => {
|
||||||
vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined });
|
vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined });
|
||||||
|
|
|
||||||
150
src/components/settings/DocsContent.locales.test.ts
Normal file
150
src/components/settings/DocsContent.locales.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
// The user guide's docs.* keys, as DocsContent and DocsSubsections read them (#401).
|
||||||
|
//
|
||||||
|
// Both components read the guide straight from the locale files: `title` and
|
||||||
|
// `overview` as strings, `features`, `steps` and `tips` as string arrays
|
||||||
|
// (`returnObjects: true`), and the optional `subsections` through
|
||||||
|
// `readSubsections`, which drops a malformed entry without a word. The test of
|
||||||
|
// DocsSubsections runs on a fixture and DocsContent has none, so these tests
|
||||||
|
// hold the real files to that shape: a string where an array is expected, a
|
||||||
|
// sub-section without its steps, or a French list longer than its English one
|
||||||
|
// would otherwise only show in the app.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import fr from "../../i18n/locales/fr.json";
|
||||||
|
import en from "../../i18n/locales/en.json";
|
||||||
|
import { SECTIONS } from "./DocsContent";
|
||||||
|
import { readSubsections } from "./DocsSubsections";
|
||||||
|
import { GETTING_STARTED_GUIDE } from "../onboarding/OnboardingBanner";
|
||||||
|
|
||||||
|
type Docs = Record<string, unknown>;
|
||||||
|
|
||||||
|
const LOCALES: Record<string, Docs> = {
|
||||||
|
fr: (fr as { docs: Docs }).docs,
|
||||||
|
en: (en as { docs: Docs }).docs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_KEYS: string[] = SECTIONS.map((section) => section.key);
|
||||||
|
|
||||||
|
/** The labels DocsContent reads besides the sections. */
|
||||||
|
const SHARED_LABELS = ["title", "print", "features", "howTo", "quickStart", "tipsHeader"];
|
||||||
|
|
||||||
|
const LISTS = ["features", "steps", "tips"] as const;
|
||||||
|
|
||||||
|
function isText(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(docs: Docs, key: string): Record<string, unknown> {
|
||||||
|
const value = docs[key];
|
||||||
|
expect(value, `docs.${key}`).toBeTypeOf("object");
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The section's sub-sections as declared, or an empty array when it has none. */
|
||||||
|
function declaredSubsections(docs: Docs, key: string): Array<Record<string, unknown>> {
|
||||||
|
const value = section(docs, key).subsections;
|
||||||
|
return value === undefined ? [] : (value as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("docs.* keys of the user guide", () => {
|
||||||
|
it("declare, in both locales, every section DocsContent renders and no other", () => {
|
||||||
|
// DocsContent renders in its own order: the order of the keys in the file does not matter.
|
||||||
|
for (const [lng, docs] of Object.entries(LOCALES)) {
|
||||||
|
const sectionsInFile = Object.keys(docs).filter(
|
||||||
|
(key) => typeof docs[key] === "object" && docs[key] !== null,
|
||||||
|
);
|
||||||
|
expect([...sectionsInFile].sort(), lng).toEqual([...SECTION_KEYS].sort());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("give every section the strings and the string arrays DocsContent reads", () => {
|
||||||
|
for (const [lng, docs] of Object.entries(LOCALES)) {
|
||||||
|
for (const key of SECTION_KEYS) {
|
||||||
|
const value = section(docs, key);
|
||||||
|
expect(isText(value.title), `${lng} docs.${key}.title`).toBe(true);
|
||||||
|
expect(isText(value.overview), `${lng} docs.${key}.overview`).toBe(true);
|
||||||
|
for (const list of LISTS) {
|
||||||
|
const items = value[list];
|
||||||
|
expect(Array.isArray(items), `${lng} docs.${key}.${list} is an array`).toBe(true);
|
||||||
|
expect((items as unknown[]).length, `${lng} docs.${key}.${list}`).toBeGreaterThan(0);
|
||||||
|
for (const item of items as unknown[]) {
|
||||||
|
expect(isText(item), `${lng} docs.${key}.${list}: ${JSON.stringify(item)}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const label of SHARED_LABELS) {
|
||||||
|
expect(isText(docs[label]), `${lng} docs.${label}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keep every declared sub-section whole: readSubsections drops none", () => {
|
||||||
|
for (const [lng, docs] of Object.entries(LOCALES)) {
|
||||||
|
for (const key of SECTION_KEYS) {
|
||||||
|
const declared = section(docs, key).subsections;
|
||||||
|
if (declared === undefined) continue;
|
||||||
|
expect(Array.isArray(declared), `${lng} docs.${key}.subsections is an array`).toBe(true);
|
||||||
|
const kept = readSubsections(declared);
|
||||||
|
expect(kept, `${lng} docs.${key}.subsections`).toHaveLength((declared as unknown[]).length);
|
||||||
|
for (const sub of kept) {
|
||||||
|
const where = `${lng} docs.${key}.subsections[${sub.id}]`;
|
||||||
|
expect(isText(sub.id), `${where}.id`).toBe(true);
|
||||||
|
expect(isText(sub.title), `${where}.title`).toBe(true);
|
||||||
|
expect(sub.steps.length, `${where}.steps`).toBeGreaterThan(0);
|
||||||
|
for (const step of sub.steps) expect(isText(step), `${where}.steps`).toBe(true);
|
||||||
|
if (sub.status !== undefined) expect(isText(sub.status), `${where}.status`).toBe(true);
|
||||||
|
if (sub.note !== undefined) expect(isText(sub.note), `${where}.note`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("give each sub-section an anchor of its own, apart from the section keys", () => {
|
||||||
|
// DocsContent keeps sections and sub-sections in one table of anchors
|
||||||
|
// (`/settings/users#import-export`): a shared id would hijack the scroll.
|
||||||
|
for (const [lng, docs] of Object.entries(LOCALES)) {
|
||||||
|
const ids = SECTION_KEYS.flatMap((key) =>
|
||||||
|
declaredSubsections(docs, key).map((sub) => String(sub.id)),
|
||||||
|
);
|
||||||
|
expect(new Set(ids).size, `${lng}: ${ids.join(", ")}`).toBe(ids.length);
|
||||||
|
for (const id of ids) expect(SECTION_KEYS, `${lng}: ${id}`).not.toContain(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("match French and English item for item", () => {
|
||||||
|
for (const key of SECTION_KEYS) {
|
||||||
|
const frSection = section(LOCALES.fr, key);
|
||||||
|
const enSection = section(LOCALES.en, key);
|
||||||
|
for (const list of LISTS) {
|
||||||
|
expect((enSection[list] as unknown[]).length, `docs.${key}.${list}`).toBe(
|
||||||
|
(frSection[list] as unknown[]).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const frSubs = declaredSubsections(LOCALES.fr, key);
|
||||||
|
const enSubs = declaredSubsections(LOCALES.en, key);
|
||||||
|
expect(enSubs.map((sub) => sub.id), `docs.${key}.subsections`).toEqual(
|
||||||
|
frSubs.map((sub) => sub.id),
|
||||||
|
);
|
||||||
|
frSubs.forEach((frSub, i) => {
|
||||||
|
const enSub = enSubs[i];
|
||||||
|
const where = `docs.${key}.subsections[${String(frSub.id)}]`;
|
||||||
|
expect((enSub.steps as unknown[]).length, `${where}.steps`).toBe(
|
||||||
|
(frSub.steps as unknown[]).length,
|
||||||
|
);
|
||||||
|
expect("status" in enSub, `${where}.status`).toBe("status" in frSub);
|
||||||
|
expect("note" in enSub, `${where}.note`).toBe("note" in frSub);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describe the getting-started path in the section that \"Open the guide\" opens", () => {
|
||||||
|
// The end of the path links to the guide by its hash (#397); the section it
|
||||||
|
// opens carries the path's own sub-section (#401).
|
||||||
|
const target = GETTING_STARTED_GUIDE.split("#")[1];
|
||||||
|
expect(SECTION_KEYS).toContain(target);
|
||||||
|
for (const [lng, docs] of Object.entries(LOCALES)) {
|
||||||
|
const ids = readSubsections(section(docs, target).subsections).map((sub) => sub.id);
|
||||||
|
expect(ids, lng).toContain("getting-started-path");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,7 +21,8 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import DocsSubsections from "./DocsSubsections";
|
import DocsSubsections from "./DocsSubsections";
|
||||||
|
|
||||||
const SECTIONS = [
|
/** The guide's sections, in order; `DocsContent.locales.test.ts` holds both locales to them. */
|
||||||
|
export const SECTIONS = [
|
||||||
{ key: "gettingStarted", icon: Rocket },
|
{ key: "gettingStarted", icon: Rocket },
|
||||||
{ key: "profiles", icon: Users },
|
{ key: "profiles", icon: Users },
|
||||||
{ key: "dashboard", icon: LayoutDashboard },
|
{ key: "dashboard", icon: LayoutDashboard },
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
initTransactionsState,
|
initTransactionsState,
|
||||||
showsEmptyState,
|
showsEmptyState,
|
||||||
GUIDED_ENTRY_FILTERS,
|
GUIDED_ENTRY_FILTERS,
|
||||||
|
type TransactionsAction,
|
||||||
type TransactionsState,
|
type TransactionsState,
|
||||||
} from "./useTransactions";
|
} from "./useTransactions";
|
||||||
import type { TransactionPageResult } from "../shared/types";
|
import type { TransactionPageResult } from "../shared/types";
|
||||||
|
|
@ -170,6 +171,32 @@ describe("transactionsReducer — the guided entry's fallback (#399)", () => {
|
||||||
expect(after.guidedFallback).toBe(false);
|
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", () => {
|
it("falls back once: an empty page after the fallback keeps the filters, so nothing reads again", () => {
|
||||||
const fellBack = result(guided(), 0);
|
const fellBack = result(guided(), 0);
|
||||||
const again = result(fellBack, 0);
|
const again = result(fellBack, 0);
|
||||||
|
|
|
||||||
|
|
@ -880,7 +880,8 @@
|
||||||
"Visual reports and interactive charts with context menu",
|
"Visual reports and interactive charts with context menu",
|
||||||
"Multiple profiles with separate databases and optional PIN",
|
"Multiple profiles with separate databases and optional PIN",
|
||||||
"Dark mode with warm gray palette",
|
"Dark mode with warm gray palette",
|
||||||
"Data export and import with optional AES-256 encryption"
|
"Data export and import with optional AES-256 encryption",
|
||||||
|
"Guided getting-started path: a banner at the top of every page and, on the page of the step in progress, a card that says what to do"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Create a profile — each profile has its own database",
|
"Create a profile — each profile has its own database",
|
||||||
|
|
@ -890,6 +891,24 @@
|
||||||
"Categorize a transaction by hand — the keyword seed does most of the work, a manual correction refines the rest",
|
"Categorize a transaction by hand — the keyword seed does most of the work, a manual correction refines the rest",
|
||||||
"Enter a first budget — Base edition and up; then track your progress through Reports"
|
"Enter a first budget — Base edition and up; then track your progress through Reports"
|
||||||
],
|
],
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"id": "getting-started-path",
|
||||||
|
"title": "The getting-started path",
|
||||||
|
"steps": [
|
||||||
|
"The getting-started banner shows at the top of every page while the path is not hidden. It shows your progress, for instance \"Getting started 3/6\", and the next step, with a link to the page where it is done. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list",
|
||||||
|
"\"Show the list\" unfolds the six steps under the banner and \"Close the list\" folds them; the list also folds when you change pages. Each step still to do leads to the page where it is done",
|
||||||
|
"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; 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: \"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": [
|
"tips": [
|
||||||
"You can switch between English and French using the language selector in the sidebar",
|
"You can switch between English and French using the language selector in the sidebar",
|
||||||
"Toggle dark mode using the button in the sidebar",
|
"Toggle dark mode using the button in the sidebar",
|
||||||
|
|
@ -918,7 +937,8 @@
|
||||||
"The PIN is requested each time you access a protected profile",
|
"The PIN is requested each time you access a protected profile",
|
||||||
"Deleting a profile permanently removes all its data — this action cannot be undone",
|
"Deleting a profile permanently removes all its data — this action cannot be undone",
|
||||||
"When you open a profile created by an earlier version, the app updates its database after keeping a copy in the migration-backups folder, next to the databases (on Linux ~/.config/com.simpl.resultat/, on Windows %APPDATA%\\com.simpl.resultat\\). The copy is deleted with the profile",
|
"When you open a profile created by an earlier version, the app updates its database after keeping a copy in the migration-backups folder, next to the databases (on Linux ~/.config/com.simpl.resultat/, on Windows %APPDATA%\\com.simpl.resultat\\). The copy is deleted with the profile",
|
||||||
"On Linux, versions up to 0.17.0 left a deleted profile's database on disk (~/.config/com.simpl.resultat/profile_….db); you can delete that file by hand"
|
"On Linux, versions up to 0.17.0 left a deleted profile's database on disk (~/.config/com.simpl.resultat/profile_….db); you can delete that file by hand",
|
||||||
|
"The getting-started path belongs to each profile: its steps tick from the profile's data, and hiding it in one profile does not hide it in the others"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|
@ -931,12 +951,12 @@
|
||||||
"Stacked bar chart of expenses by category and month",
|
"Stacked bar chart of expenses by category and month",
|
||||||
"Adjustable time period selector (default: year to date)",
|
"Adjustable time period selector (default: year to date)",
|
||||||
"Context menu (right-click) to hide a category or view its transactions",
|
"Context menu (right-click) to hide a category or view its transactions",
|
||||||
"Getting-started checklist for a new profile: six boxes that tick themselves from your data, with a \"Hide\" button"
|
"Welcome card for a profile without transactions, with the getting-started checklist: six steps that tick themselves from your data, and a \"Hide\" button"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"In a profile without transactions, the dashboard shows a welcome card and the getting-started checklist: create a profile (already ticked), choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget. Each unticked row leads to the page where it is done; the Budget box is marked \"Base and up\" in the Free edition and does not count towards the total",
|
"In a profile without transactions, the dashboard shows a welcome card and the getting-started checklist: create a profile (already ticked), choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget. Each unticked row leads to the page where it is done; the Budget box is marked \"Base and up\" in the Free edition and does not count towards the total",
|
||||||
"After your first import, the list shrinks to a compact bar above the filters, with its counter (for instance 4 / 6), and disappears on its own once every box is ticked",
|
"Once the profile has transactions, the checklist leaves the dashboard: the getting-started banner, at the top of every page, takes over until the end of the path (see Getting Started → The getting-started path)",
|
||||||
"\"Hide\" removes the list from this profile only; you can show it again from Settings → Users",
|
"\"Hide\" removes the checklist from the welcome card, and the banner from every page, for this profile only; Settings → Users shows them again",
|
||||||
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
|
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
|
||||||
"Review the summary cards for your balance, total income, and total expenses",
|
"Review the summary cards for your balance, total income, and total expenses",
|
||||||
"Check the pie chart to see how your spending is distributed across categories",
|
"Check the pie chart to see how your spending is distributed across categories",
|
||||||
|
|
@ -1256,7 +1276,7 @@
|
||||||
"Data import from a previously exported file",
|
"Data import from a previously exported file",
|
||||||
"Optional AES-256-GCM encryption for exported files",
|
"Optional AES-256-GCM encryption for exported files",
|
||||||
"Optional feedback submission to feedback.lacompagniemaximus.com (explicit exception to the 100% local operation — prompts for consent)",
|
"Optional feedback submission to feedback.lacompagniemaximus.com (explicit exception to the 100% local operation — prompts for consent)",
|
||||||
"Show the dashboard's getting-started checklist again (Settings → Users) once it was hidden"
|
"Show the getting-started path again (Settings → Users) once it was hidden"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Click User Guide to access the full documentation",
|
"Click User Guide to access the full documentation",
|
||||||
|
|
@ -1266,7 +1286,7 @@
|
||||||
"When exporting, choose what to include and optionally set a password for encryption",
|
"When exporting, choose what to include and optionally set a password for encryption",
|
||||||
"When importing, select a previously exported file — encrypted files will prompt for the password",
|
"When importing, select a previously exported file — encrypted files will prompt for the password",
|
||||||
"Click Send feedback in the Logs section to share a suggestion, comment, or issue — the identify and context/logs checkboxes are unchecked by default",
|
"Click Send feedback in the Logs section to share a suggestion, comment, or issue — the identify and context/logs checkboxes are unchecked by default",
|
||||||
"To see the getting-started checklist again after hiding it, open Settings → Users and click \"Show the getting-started checklist\"; it reappears on this profile's dashboard. The button is only visible once the list was hidden"
|
"To see the getting-started path again after hiding it, open Settings → Users and click \"Show the getting-started checklist\": the path reappears at the top of this profile's pages, and a message confirms it. The button shows as soon as the path is hidden, even from the banner of that same page"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Updates only replace the app binary — your database is never modified",
|
"Updates only replace the app binary — your database is never modified",
|
||||||
|
|
|
||||||
|
|
@ -880,7 +880,8 @@
|
||||||
"Rapports visuels et graphiques interactifs avec menu contextuel",
|
"Rapports visuels et graphiques interactifs avec menu contextuel",
|
||||||
"Profils multiples avec bases de données séparées et NIP optionnel",
|
"Profils multiples avec bases de données séparées et NIP optionnel",
|
||||||
"Mode sombre avec palette gris chaud",
|
"Mode sombre avec palette gris chaud",
|
||||||
"Export et import de données avec chiffrement AES-256 optionnel"
|
"Export et import de données avec chiffrement AES-256 optionnel",
|
||||||
|
"Parcours de démarrage guidé : un bandeau en haut de chaque page et, sur la page de l'étape en cours, une carte qui dit quoi faire"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Créer un profil — chaque profil a sa propre base de données",
|
"Créer un profil — chaque profil a sa propre base de données",
|
||||||
|
|
@ -890,6 +891,24 @@
|
||||||
"Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste",
|
"Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste",
|
||||||
"Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports"
|
"Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports"
|
||||||
],
|
],
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"id": "getting-started-path",
|
||||||
|
"title": "Le parcours de démarrage",
|
||||||
|
"steps": [
|
||||||
|
"Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/6 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste",
|
||||||
|
"« Voir la liste » déplie les six étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire",
|
||||||
|
"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é ; 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 : « 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": [
|
"tips": [
|
||||||
"Vous pouvez basculer entre le français et l'anglais via le sélecteur de langue dans la barre latérale",
|
"Vous pouvez basculer entre le français et l'anglais via le sélecteur de langue dans la barre latérale",
|
||||||
"Activez le mode sombre via le bouton dans la barre latérale",
|
"Activez le mode sombre via le bouton dans la barre latérale",
|
||||||
|
|
@ -918,7 +937,8 @@
|
||||||
"Le NIP est demandé à chaque fois que vous accédez à un profil protégé",
|
"Le NIP est demandé à chaque fois que vous accédez à un profil protégé",
|
||||||
"La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible",
|
"La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible",
|
||||||
"À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier migration-backups, à côté des bases (sous Linux ~/.config/com.simpl.resultat/, sous Windows %APPDATA%\\com.simpl.resultat\\). La copie est supprimée avec le profil",
|
"À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier migration-backups, à côté des bases (sous Linux ~/.config/com.simpl.resultat/, sous Windows %APPDATA%\\com.simpl.resultat\\). La copie est supprimée avec le profil",
|
||||||
"Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (~/.config/com.simpl.resultat/profile_….db) ; vous pouvez supprimer ce fichier à la main"
|
"Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (~/.config/com.simpl.resultat/profile_….db) ; vous pouvez supprimer ce fichier à la main",
|
||||||
|
"Le parcours de démarrage est propre à chaque profil : ses étapes se cochent sur les données du profil, et le masquer dans un profil ne le masque pas dans les autres"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|
@ -931,12 +951,12 @@
|
||||||
"Histogramme empilé des dépenses par catégorie et par mois",
|
"Histogramme empilé des dépenses par catégorie et par mois",
|
||||||
"Sélecteur de période ajustable (par défaut : année à ce jour)",
|
"Sélecteur de période ajustable (par défaut : année à ce jour)",
|
||||||
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions",
|
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions",
|
||||||
"Liste de démarrage pour un profil neuf : six cases qui se cochent d'elles-mêmes selon vos données, avec un bouton « Masquer »"
|
"Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : six étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total",
|
"Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total",
|
||||||
"Après votre premier import, la liste se réduit à une barre compacte au-dessus des filtres, avec son compteur (par exemple 4 / 6), et disparaît d'elle-même quand toutes les cases sont cochées",
|
"Dès que le profil a des transactions, la liste quitte le tableau de bord : le bandeau de démarrage, en haut de chaque page, la remplace jusqu'à la fin du parcours (voir Premiers pas → Le parcours de démarrage)",
|
||||||
"« Masquer » retire la liste de ce profil seulement ; vous pouvez la réafficher depuis Paramètres → Utilisateurs",
|
"« Masquer » retire la liste de la carte d'accueil, et le bandeau de toutes les pages, pour ce profil seulement ; Paramètres → Utilisateurs les réaffiche",
|
||||||
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
|
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
|
||||||
"Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales",
|
"Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales",
|
||||||
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
|
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
|
||||||
|
|
@ -1256,7 +1276,7 @@
|
||||||
"Import des données depuis un fichier exporté précédemment",
|
"Import des données depuis un fichier exporté précédemment",
|
||||||
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés",
|
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés",
|
||||||
"Envoi de feedback optionnel vers feedback.lacompagniemaximus.com (exception explicite au fonctionnement 100% local — déclenche une demande de consentement)",
|
"Envoi de feedback optionnel vers feedback.lacompagniemaximus.com (exception explicite au fonctionnement 100% local — déclenche une demande de consentement)",
|
||||||
"Réaffichage de la liste de démarrage du tableau de bord (Paramètres → Utilisateurs) quand elle a été masquée"
|
"Réaffichage du parcours de démarrage (Paramètres → Utilisateurs) quand il a été masqué"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
|
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
|
||||||
|
|
@ -1266,7 +1286,7 @@
|
||||||
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
|
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
|
||||||
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe",
|
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe",
|
||||||
"Cliquez sur Envoyer un feedback dans la section Journaux pour partager une suggestion, un commentaire ou un problème — la case d'identification et d'envoi du contexte/logs sont décochées par défaut",
|
"Cliquez sur Envoyer un feedback dans la section Journaux pour partager une suggestion, un commentaire ou un problème — la case d'identification et d'envoi du contexte/logs sont décochées par défaut",
|
||||||
"Pour revoir la liste de démarrage après l'avoir masquée, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » ; elle réapparaît sur le tableau de bord de ce profil. Le bouton n'est visible que si la liste a été masquée"
|
"Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
|
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,13 @@ export default function BudgetPage() {
|
||||||
|
|
||||||
// The getting-started card follows the grid (#400). Its target is
|
// 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
|
// highlighted only while the card is on screen: a ring needs the card that
|
||||||
// explains it.
|
// explains it. The grid's last report does not outlive the grid: the rows
|
||||||
const cardModel = budgetCardModel({ target: gridTarget }, onboarding.checklist);
|
// 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 =
|
const highlight =
|
||||||
cardModel.target !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname);
|
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", () => {
|
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);
|
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,
|
scannedSources: SOURCES,
|
||||||
importReport: report(0),
|
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);
|
harness.onboarding = path(IMPORTED, true);
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(sentence(html)).toBe("t(onboarding.card.import.failed[])");
|
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");
|
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", () => {
|
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.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||||
harness.onboarding = path(IMPORTED, true);
|
harness.onboarding = path(IMPORTED, true);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
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.
|
// The page's services pull the database: stub the connection.
|
||||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
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 }));
|
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("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
||||||
|
|
@ -30,7 +35,8 @@ vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
||||||
...actual,
|
...actual,
|
||||||
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
|
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
|
||||||
const hook = actual.useTransactions(...args);
|
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(() => {
|
beforeEach(() => {
|
||||||
harness.onboarding = path(IMPORTED);
|
harness.onboarding = path(IMPORTED);
|
||||||
harness.rows = [];
|
harness.rows = [];
|
||||||
|
harness.filters = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TransactionsPage — the guided entry (#399)", () => {
|
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[])");
|
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", () => {
|
it("the first row's category is the one target", () => {
|
||||||
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
|
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
|
||||||
const html = render(GUIDED);
|
const html = render(GUIDED);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ vi.mock("./db", () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { getDb } from "./db";
|
import { getDb, withTransaction } from "./db";
|
||||||
import {
|
import {
|
||||||
getExportImportSources,
|
getExportImportSources,
|
||||||
getExportImportTemplates,
|
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");
|
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 () => {
|
it("a restore rolled back announces nothing", async () => {
|
||||||
let txInserts = 0;
|
let txInserts = 0;
|
||||||
wire(
|
wire(
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,9 @@ export async function createImportedFile(file: {
|
||||||
"SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2",
|
"SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2",
|
||||||
[file.source_id, file.filename]
|
[file.source_id, file.filename]
|
||||||
);
|
);
|
||||||
// Both branches write the status that the "import a first file" step counts
|
// Both branches write the status that the "import a first file" step reads
|
||||||
// (`status = 'completed'`), so each one is announced (ADR 0022).
|
// (`status = 'completed'`, counted once the file brought a transaction), so
|
||||||
|
// each one is announced (ADR 0022).
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
`UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`,
|
`UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`,
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,6 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
||||||
expect(db.select).toHaveBeenCalledTimes(4);
|
expect(db.select).toHaveBeenCalledTimes(4);
|
||||||
const sqls = db.select.mock.calls.map((c) => String(c[0]));
|
const sqls = db.select.mock.calls.map((c) => String(c[0]));
|
||||||
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
|
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
|
||||||
expect(aggregate).toContain("FROM imported_files WHERE status = 'completed'");
|
|
||||||
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
|
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
|
||||||
expect(aggregate).toContain("FROM budget_entries");
|
expect(aggregate).toContain("FROM budget_entries");
|
||||||
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
|
expect(aggregate).toMatch(/\(SELECT COUNT\(\*\) FROM transactions\)\s+AS transactions/);
|
||||||
|
|
@ -144,6 +143,21 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
||||||
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
|
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 () => {
|
it("is a pure read: it never writes, even on a profile D9 would hide", async () => {
|
||||||
const { db } = useFakeDb({ transactions: 60 });
|
const { db } = useFakeDb({ transactions: 60 });
|
||||||
const unsubscribe = subscribeDataChanged(() => {
|
const unsubscribe = subscribeDataChanged(() => {
|
||||||
|
|
@ -246,9 +260,19 @@ describe("initializeGuidedPath — rule D9 (#396)", () => {
|
||||||
["INSERT", ONBOARDING_GUIDED_INITIALIZED_KEY],
|
["INSERT", ONBOARDING_GUIDED_INITIALIZED_KEY],
|
||||||
["COMMIT", undefined],
|
["COMMIT", undefined],
|
||||||
]);
|
]);
|
||||||
// The flag and the transaction count are read after BEGIN.
|
// The flag is read once outside any transaction, then again after BEGIN,
|
||||||
const firstSelect = fake.db.select.mock.invocationCallOrder[0];
|
// with the transaction count.
|
||||||
expect(firstSelect).toBeGreaterThan(fake.db.execute.mock.invocationCallOrder[0]);
|
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({
|
expect(fake.prefs()).toEqual({
|
||||||
[ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1",
|
[ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1",
|
||||||
[ONBOARDING_GUIDED_INITIALIZED_KEY]: "1",
|
[ONBOARDING_GUIDED_INITIALIZED_KEY]: "1",
|
||||||
|
|
@ -264,17 +288,57 @@ describe("initializeGuidedPath — rule D9 (#396)", () => {
|
||||||
expect(logInfo).not.toHaveBeenCalled();
|
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({
|
const fake = useFakeDb({
|
||||||
transactions: 3,
|
transactions: 3,
|
||||||
prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" },
|
prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" },
|
||||||
});
|
});
|
||||||
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
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(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
|
||||||
expect(logInfo).not.toHaveBeenCalled();
|
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 () => {
|
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.
|
// D9 hid it, the user showed it again from Settings → Users.
|
||||||
const fake = useFakeDb({ transactions: 3 });
|
const fake = useFakeDb({ transactions: 3 });
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,16 @@ interface CountersRow {
|
||||||
transactions: number;
|
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 = `
|
const COUNTERS_SQL = `
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*) FROM import_sources) AS sources,
|
(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 transactions WHERE is_manually_categorized = 1) AS manual_categorized,
|
||||||
(SELECT COUNT(*) FROM budget_entries) AS budget_entries,
|
(SELECT COUNT(*) FROM budget_entries) AS budget_entries,
|
||||||
(SELECT COUNT(*) FROM transactions) AS transactions
|
(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
|
* Rule D9, once per profile: at the first opening under this version, a
|
||||||
* profile that already has transactions gets the path hidden (same preference
|
* 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
|
* 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
|
* it.
|
||||||
* 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
|
* The flag is read first, outside any transaction: once D9 ran, every later
|
||||||
* leaves the flag unset, so the next opening tries again.
|
* 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
|
* 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.
|
* writes to the in-app log. Not announced on the data channel.
|
||||||
*/
|
*/
|
||||||
export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
|
export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
|
||||||
|
if ((await getPreference(ONBOARDING_GUIDED_INITIALIZED_KEY)) === "1") {
|
||||||
|
return { hidden: false };
|
||||||
|
}
|
||||||
const hidden = await withTransaction(async (db) => {
|
const hidden = await withTransaction(async (db) => {
|
||||||
await db.execute("BEGIN");
|
await db.execute("BEGIN");
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -960,6 +960,7 @@ export interface AccountReturn {
|
||||||
/** Aggregated counters behind the getting-started checklist, read in one query. */
|
/** Aggregated counters behind the getting-started checklist, read in one query. */
|
||||||
export interface OnboardingCounters {
|
export interface OnboardingCounters {
|
||||||
sources: number;
|
sources: number;
|
||||||
|
/** `completed` imported files that brought at least one transaction. */
|
||||||
imports: number;
|
imports: number;
|
||||||
manualCategorized: number;
|
manualCategorized: number;
|
||||||
budgetEntries: number;
|
budgetEntries: number;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
// - WCAG 1.4.11: the ring reaches 3:1 against the page background and the
|
// - 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`);
|
// 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;
|
// - 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 { describe, it, expect } from "vitest";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { resolve } from "path";
|
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(inner, "the highlight rule under reduced motion").toBeDefined();
|
||||||
expect(declarations(inner!.body).get("animation")).toBe("none");
|
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;
|
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 {
|
@keyframes onboarding-pulse {
|
||||||
from {
|
from {
|
||||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
|
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:
|
* are tried in order, and the first that applies wins:
|
||||||
* 1. the folder is not read yet: no card (null);
|
* 1. the folder is not read yet: no card (null);
|
||||||
* 2. an import is running: "Importing…". It comes before anything the
|
* 2. an import is running: "Importing…". It comes before anything the
|
||||||
* counters say, because `executeImport` writes the `imported_files` row
|
* counters say, because the import step ticks as soon as the first
|
||||||
* `completed` before inserting a single transaction;
|
* transaction of the file lands, long before the import ends;
|
||||||
* 3. the report: "Done." when the import added transactions, the failure
|
* 3. the report: "Done." when the import added transactions, the failure
|
||||||
* sentence when it added none (insertion failed, duplicates only), or when
|
* sentence when it added none (insertion failed, duplicates only), or when
|
||||||
* the count is missing: never "Done." without one;
|
* the count is missing: never "Done." without one;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue