Document the final multi-level category collapse behavior (shipped in #288/#289/#290) and the structural decision to persist per-profile UI state in user_preferences rather than localStorage. - ADR 0016 (accepted): profile-specific UI state (category collapse) lives in the profile's own SQLite user_preferences table, not localStorage. deleteProfile drops the .db but purges no localStorage, so a per-profile localStorage key would be a residue surviving profile deletion, leaking which categories a (possibly PIN-protected) profile explored. States the boundary: profile-specific -> user_preferences; machine-global (theme, subtotals position, Cartes period mode) -> localStorage. - guide-utilisateur.md sections 8 (Budget) + 9 (Reports): multi-level collapse, collapsed-by-default, "Expand all / Collapse all" button, memory per profile. - docs.* i18n keys (fr + en): mirror the guide additions in the in-app help page (docs.budget + docs.reports features/tips). - architecture.md: user_preferences line now names the 4 collapse keys, useCollapsibleGroups cross-cutting hook note, ADR index row. No DB migration, no behavior change (docs only). Build clean, 828 vitest. Resolves #291 Generated autonomously by /autopilot run of 2026-07-15 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
ADR 0016 — Persistance de l'état UI par profil : le repli des catégories vit dans user_preferences
- Status: Accepted
- Date: 2026-07-15
- Issues: #288 (socle multi-niveaux + 3 rapports + bascule vers
user_preferences), #289 (grille budget), #290 (unification des deux arbres de catégories), #291 (cet ADR + doc) - Spec:
spec-decisions-collapse-multi-niveaux.md,spec-plan-collapse-multi-niveaux.md(plan v2) - S'appuie sur ADR 0005 (bases SQLite séparées par profil) et ADR 0002 (état local, pas de store global)
Contexte
Le repli/dépli des catégories a d'abord été introduit sur les deux rapports comparables (issue #254/#265) : un Set d'ID de catégories par rapport, persisté en localStorage, replié par défaut. La généralisation (issues #288/#289/#290) a étendu ce mécanisme à tous les niveaux de la hiérarchie et à six surfaces : les trois tableaux de rapports hiérarchiques (comparable réel-vs-réel ComparePeriodTable, réel-vs-budget BudgetVsActualTable, tendance par catégorie CategoryOverTimeTable), la grille budgétaire (BudgetTable), et les deux arbres de gestion des catégories (CategoryTree de la page Catégories + CategoryTaxonomyTree du guide standard et de l'assistant de migration).
Cette généralisation a forcé une question qui n'était pas tranchée en #254 : où vit l'état de repli ? Deux propriétés du produit rendent le choix structurant, pas cosmétique.
-
L'app est multi-profils à bases séparées (ADR 0005) : chaque profil a sa propre base SQLite, ses propres catégories, ses propres ID de catégories. Un profil peut être protégé par un NIP (Argon2). Supprimer un profil = supprimer son fichier
.db(deleteProfileDb→ commande Rustdelete_profile_db) + retirer son entrée deprofiles.json. Rien d'autre n'est nettoyé —deleteProfile(ProfileContext) ne touche jamaislocalStorage. -
localStorageest machine-global, partagé par tous les profils du poste. La clé de repli est un ensemble d'ID de catégories (p:<categoryId>), et ces ID n'ont de sens que dans la base d'un profil donné : le même ID numérique désigne des catégories différentes selon le profil. Une clé de repli enlocalStorageserait donc soit en collision entre profils (mêmes ID, catégories différentes), soit suffixée parprofileId— et dans ce dernier cas, elle survivrait à la suppression du profil, devenant un résidu.
Ce résidu n'est pas anodin : l'ensemble des catégories qu'un profil a dépliées révèle quelles postes de dépense l'utilisateur a explorés. Le laisser traîner en localStorage après la suppression d'un profil (potentiellement NIP-protégé) trahit une part de son comportement financier. C'est incompatible avec le principe privacy-first (non négociable) du projet.
Décision
L'état UI spécifique à un profil est persisté dans la table user_preferences de la base du profil ; l'état UI machine-global reste dans localStorage. Le repli des catégories relève du premier cas.
1. Frontière user_preferences vs localStorage
| Nature de l'état | Support | Pourquoi | Exemples |
|---|---|---|---|
| Spécifique au profil (dépend des données du profil, ou révèle son usage) | user_preferences (base SQLite du profil) |
Scoping par-profil gratuit (la clé vit dans la base du profil) ; détruit avec le profil (drop du .db) ⇒ zéro résidu |
Repli des catégories des rapports + grille budget (clés reports-compare-expanded, reports-bva-expanded, reports-trends-expanded, budget-grid-expanded) ; import_folder, balance_show_returns, balance_starter_proposed (préexistants) |
| Machine-globale (scalaire agnostique du profil, ne révèle rien de sensible) | localStorage |
On veut la même valeur quel que soit le profil ouvert ; aucune fuite si elle survit à une suppression | Thème clair/sombre (theme), position des sous-totaux (*-subtotals-position), mode de période Cartes (reports-cartes-period-mode), bascule graphique/tableau par rapport |
Le critère : une préférence est machine-globale quand sa valeur est un scalaire agnostique du profil ("top"/"bottom", "dark", "chart") qu'on souhaite partager entre tous les profils du poste. Elle est spécifique au profil dès qu'elle est indexée par des identifiants propres à la base du profil (les ID de catégories du repli) ou qu'elle expose l'usage d'un profil donné.
2. Mécanisme — useCollapsibleGroups(storageKey: string | null)
Le hook useCollapsibleGroups porte cette décision via un unique paramètre storageKey :
storageKeynon nul ⇒ persistance dansuser_preferencesviauserPreferenceService(getPreference/setPreference, UPSERT surkey). Utilisé par les quatre surfaces persistées (3 rapports + budget). La valeur sérialisée est leSetdes clés dont l'état diffère du défaut — ce qui permet un défaut « tout replié » sans graine (Set vide = tout replié pourdefaultExpanded: false).storageKey === null⇒ aucune persistance, état purement en mémoire, réinitialisé au défaut à chaque montage. Utilisé par les deux arbres de catégories (page Catégories, guide standard, assistant de migration) : leur repli est un confort de navigation éphémère, sans intérêt à conserver ni à révéler.
Le hook reste sans contexte — il n'appelle jamais useProfile() : la clé user_preferences est déjà par-profil, puisqu'elle vit dans la base du profil actif. Aucun profileId à faufiler.
3. Rationale privacy — le résidu qui survit à la suppression
C'est l'argument central. deleteProfile détruit la base (.db) mais ne purge aucun localStorage. Une clé de repli par-profil en localStorage (ex. reports-compare-expanded:<profileId>) survivrait donc à la suppression du profil, laissant deviner quelles catégories un profil désormais supprimé — et peut-être NIP-protégé — avait explorées. En logeant l'état dans user_preferences, il disparaît avec le fichier .db : la suppression du profil emporte automatiquement son état UI, sans code de nettoyage dédié et sans surface de fuite. La propriété « supprimer un profil = supprimer un fichier » (ADR 0005) est préservée telle quelle.
Alternatives considérées
A. localStorage suffixé par profileId — rejeté
Garder le repli en localStorage avec une clé …:<profileId> par profil. Rejeté : c'est précisément le résidu décrit ci-dessus (survit à deleteProfile, fuite privacy), et il faudrait ajouter un balayage de nettoyage dans deleteProfile — du code fragile qui doit connaître toutes les clés UI par-profil de l'app, à maintenir à chaque nouvelle surface repliable.
B. Table SQLite dédiée (ui_collapse_state) — rejeté
Créer une table par-profil rien que pour le repli. Rejeté : sur-dimensionné. user_preferences est déjà une table clé-valeur par-profil faite pour ça (import_folder, balance_show_returns…) ; une clé par surface suffit, sans migration.
C. Tout en mémoire (aucune persistance) — rejeté pour les rapports/budget
Simple, zéro résidu. Rejeté pour les quatre surfaces persistées : sur des tableaux profonds, re-replier à chaque visite est une friction réelle (finding remonté en #254). Retenu en revanche pour les deux arbres de catégories (storageKey: null) — d'où le paramètre nullable plutôt qu'un choix uniforme.
Consequences
Positives
- Zéro résidu, zéro fuite : l'état de repli est détruit avec le
.dbdu profil ; aucune trace machine-globale de l'usage d'un profil supprimé. Conforme privacy-first. - Scoping par-profil gratuit : la clé vit dans la base du profil ⇒ isolation automatique, hook sans contexte, pas de
profileIdà propager. - Réutilise l'existant :
user_preferences+userPreferenceServicedéjà en place ⇒ aucune migration, aucune table, aucun changement de schéma. - Frontière tracée : la règle « profil-spécifique →
user_preferences; machine-globale →localStorage» est explicite (§1) ⇒ le prochain état UI par-profil ne repartira pas enlocalStoragepar défaut (caveat de review levé).
Négatives / risques actés
- Hydratation asynchrone :
getPreference/setPreferencesontasync(passent pargetDb()). L'état initial rendu est le défaut (tout replié pour les rapports/budget), puis unuseEffecthydrate depuisuser_preferences. Pas de flash visible : le défaut EST ce qui s'affiche en premier, et l'hydratation ne peut que révéler ce que l'utilisateur avait déplié (jamais masquer ce qui était visible). Acceptable — c'est la contrepartie assumée du choix. - Best-effort : une lecture/écriture en échec retombe silencieusement sur l'état par défaut (jamais de crash). Une écriture perdue = la session suivante rouvre au défaut.
- Frontière à faire respecter dans la durée : rien dans le type ne force un futur état par-profil à choisir
user_preferences. La règle est documentaire (cet ADR +docs/architecture.md) — d'où l'importance de l'avoir tracée.
Neutre
- Les préférences machine-globales existantes (
theme,*-subtotals-position,reports-cartes-period-mode, bascule graphique/tableau) restent enlocalStorage: elles sont volontairement partagées entre profils et ne révèlent rien de sensible en cas de suppression. Aucune migration de ces clés n'est entreprise.
Liens
- ADR 0005 — bases SQLite séparées par profil ; « supprimer un profil = supprimer un fichier
.db» (fondement de l'argument résidu) - ADR 0002 — état local par domaine, pas de store global (le repli reste un état UI local persisté à la marge)
src/hooks/useCollapsibleGroups.ts— implémentation (storageKey: string | null,defaultExpanded, hydratation async)src/utils/collapsibleRows.ts— helpers purs (marche des ancêtres, polarité duSet)src/services/userPreferenceService.ts—getPreference/setPreference(UPSERT suruser_preferences)- Issues #254/#265 (repli d'origine, localStorage) → #288/#289/#290 (généralisation + bascule
user_preferences) → #291 (cette doc)