Compare commits

..

No commits in common. "main" and "issue-48-gate-auto-updates" have entirely different histories.

107 changed files with 1962 additions and 10407 deletions

View file

@ -25,8 +25,7 @@ jobs:
apt-get update apt-get update
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
curl wget git ca-certificates build-essential pkg-config \ curl wget git ca-certificates build-essential pkg-config \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \ libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev
libdbus-1-dev
# Node.js is required by actions/checkout and actions/cache (they # Node.js is required by actions/checkout and actions/cache (they
# are JavaScript actions and need `node` in the container PATH). # are JavaScript actions and need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
@ -64,16 +63,6 @@ jobs:
- name: cargo test - name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
# Informational audit of transitive dependencies. Failure does not
# block the CI (advisories can appear on unrelated crates and stall
# unrelated work); surface them in the job log so we see them on
# every PR run and can react in a follow-up.
- name: cargo audit
continue-on-error: true
run: |
cargo install --locked cargo-audit || true
cargo audit --file src-tauri/Cargo.lock || true
frontend: frontend:
runs-on: ubuntu runs-on: ubuntu
container: ubuntu:22.04 container: ubuntu:22.04

View file

@ -31,7 +31,7 @@ jobs:
- name: Install Linux dependencies - name: Install Linux dependencies
run: | run: |
apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils libdbus-1-dev apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils
- name: Install Windows cross-compile dependencies - name: Install Windows cross-compile dependencies
run: | run: |

View file

@ -2,76 +2,11 @@
## [Non publié] ## [Non publié]
## [0.8.2] - 2026-04-17
### Ajouté
- **Widget Feedback Hub** (Paramètres → Journaux) : un bouton *Envoyer un feedback* dans la carte Journaux ouvre un dialogue pour soumettre suggestions, commentaires ou rapports de bogue vers le Feedback Hub central. Un dialogue de consentement (affiché une seule fois) explique que l'envoi atteint `feedback.lacompagniemaximus.com` — une exception explicite au fonctionnement 100 % local de l'app. Trois cases à cocher opt-in (toutes décochées par défaut) : inclure le contexte de navigation (page, thème, écran, version, OS), inclure les derniers logs d'erreur, m'identifier avec mon compte Maximus. L'envoi passe par une commande Rust côté backend, donc rien ne quitte la machine tant que l'utilisateur n'a pas cliqué *Envoyer* (#67)
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
### Modifié
- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96)
## [0.8.0] - 2026-04-14
### Ajouté
- **Hub des rapports** : `/reports` devient un hub affichant un panneau de faits saillants (solde mois courant + cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes) et quatre cartes de navigation vers des sous-rapports dédiés (#69#76)
- **Rapport Faits saillants** (`/reports/highlights`) : tuiles de solde avec sparklines 12 mois, tableau triable des top mouvements, graphique en barres divergentes, liste des grosses transactions avec fenêtre 30/60/90 jours (#71)
- **Rapport Tendances** (`/reports/trends`) : bascule interne entre flux global (revenus vs dépenses) et évolution par catégorie, toggle graphique/tableau sur les deux (#72)
- **Rapport Comparables** (`/reports/compare`) : barre d'onglets pour Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget ; graphique en barres divergentes centré sur zéro pour les deux premiers modes (#73)
- **Zoom catégorie** (`/reports/category`) : analyse ciblée avec donut chart de la répartition par sous-catégorie, graphique d'évolution mensuelle en aires, et tableau filtrable des transactions (#74)
- **Édition contextuelle des mots-clés** : clic droit sur n'importe quelle ligne de transaction pour ajouter sa description comme mot-clé de catégorisation ; un dialog de prévisualisation montre toutes les transactions qui seraient recatégorisées (limitées à 50, avec checkbox explicite pour les suivantes) avant validation. Disponible sur le zoom catégorie, la liste des faits saillants, et la page Transactions principale (#74, #75)
- **Période bookmarkable** : la période des rapports vit maintenant dans l'URL (`?from=YYYY-MM-DD&to=YYYY-MM-DD`), vous pouvez copier, coller et partager le lien en conservant l'état (#70)
- **Préférence chart/table** mémorisée dans `localStorage` par section de rapport
### Modifié
- Le hook monolithique `useReports` a été splitté en hooks par domaine (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) pour que chaque sous-rapport ne possède que l'état qu'il utilise (#70)
- Le menu contextuel (clic droit) des rapports est désormais un composant générique `ContextMenu` réutilisé par le menu des graphiques existant et le nouveau dialog d'ajout de mot-clé (#69)
### Supprimé
- Le tableau croisé dynamique a été retiré. Plus de 90 % de son usage réel consistait à zoomer sur une catégorie, ce que le nouveau rapport Zoom catégorie traite mieux. L'historique git préserve l'ancienne implémentation si jamais elle doit revenir (#69)
### Sécurité
- Le nouveau `AddKeywordDialog` impose une longueur de 2 à 64 caractères sur les mots-clés utilisateurs pour empêcher les attaques ReDoS sur de grands ensembles de transactions (CWE-1333), utilise des requêtes `LIKE` paramétrées pour la prévisualisation (CWE-89), encapsule l'INSERT + les UPDATE par transaction dans une transaction SQL BEGIN/COMMIT/ROLLBACK explicite (CWE-662), affiche toutes les descriptions non-sûres via rendu React enfants (CWE-79), et ne recatégorise que les lignes explicitement cochées par l'utilisateur — jamais rétroactivement. Le remplacement d'un mot-clé existant sur une autre catégorie nécessite une confirmation explicite (#74)
- `getCategoryZoom` parcourt l'arbre des catégories via une CTE récursive **bornée** (`WHERE depth < 5`), protégeant contre les cycles `parent_id` malformés (CWE-835) (#74)
## [0.7.4] - 2026-04-14
### Modifié
- Les tokens OAuth sont maintenant stockés dans le trousseau du système d'exploitation (Credential Manager sous Windows, Secret Service sous Linux) au lieu d'un fichier JSON en clair. Les utilisateurs existants sont migrés de façon transparente au prochain rafraîchissement de session ; l'ancien fichier est écrasé avec des zéros puis supprimé. Une bannière « tokens en stockage local » apparaît dans les Paramètres si le trousseau est indisponible (#66, #78, #79, #81)
- Le cache d'informations de compte est désormais signé par HMAC avec une clé stockée dans le trousseau : modifier manuellement le champ `subscription_status` dans `account.json` ne permet plus de contourner le gating Premium (#80)
- Hachage du PIN migré de SHA-256 vers Argon2id pour résistance au brute-force (CWE-916). Les PINs SHA-256 existants sont vérifiés de façon transparente et rehachés au prochain déverrouillage réussi ; les nouveaux PINs utilisent Argon2id (#54)
### Sécurité
- Correction de CWE-312 (stockage en clair des tokens OAuth), CWE-345 (absence de vérification d'intégrité du cache d'abonnement) et CWE-916 (hachage faible du PIN). Les anciens fichiers `tokens.json` et les caches `account.json` non signés sont rejetés par le chemin de gating jusqu'à ce que le prochain rafraîchissement rétablisse un anchor de confiance dans le trousseau (#66, #54)
## [0.7.3] - 2026-04-13
### Corrigé
- Connexion Compte Maximus : le callback deep-link utilise maintenant l'API canonique Tauri v2 `on_open_url`, donc le code d'autorisation parvient bien à l'app en cours d'exécution au lieu de laisser l'interface bloquée en « chargement » (#51, #65)
- Les callbacks OAuth contenant un paramètre `error` remontent maintenant l'erreur à l'interface au lieu d'être ignorés silencieusement (#51)
## [0.7.2] - 2026-04-13
### Modifié
- Les mises à jour automatiques sont temporairement ouvertes à l'édition Gratuite en attendant que le serveur de licences (issue #49) soit en ligne. Le gating sera restauré une fois l'activation payante fonctionnelle de bout en bout (#48)
## [0.7.1] - 2026-04-13
### Corrigé
- Connexion Compte Maximus : le callback OAuth2 revient maintenant correctement dans l'instance en cours au lieu de lancer une deuxième instance et de laisser l'app d'origine bloquée en « chargement » (#51, #65)
## [0.7.0] - 2026-04-11
### Ajouté ### Ajouté
- CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60) - CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60)
- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
- Carte Compte Maximus dans les Paramètres : connexion optionnelle via OAuth2 PKCE pour les fonctionnalités Premium (#51)
- Activation de machines : activer/désactiver des machines via le serveur de licences, voir les machines activées dans la carte licence (#53)
- Vérification quotidienne de l'abonnement : rafraîchit automatiquement les infos du compte une fois par jour au lancement (#51)
### Modifié ### Modifié
- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48) - Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48)
- La détection d'édition prend maintenant en compte l'abonnement Compte Maximus : Premium remplace Base quand l'abonnement est actif (#51)
## [0.6.7] - 2026-03-29 ## [0.6.7] - 2026-03-29

View file

@ -2,76 +2,11 @@
## [Unreleased] ## [Unreleased]
## [0.8.2] - 2026-04-17
### Added
- **Feedback Hub widget** (Settings → Logs): a *Send feedback* button in the Logs card opens a dialog to submit suggestions, comments, or bug reports to the central Feedback Hub. A one-time consent prompt explains that submission reaches `feedback.lacompagniemaximus.com` — an explicit exception to the app's 100% local operation. Three opt-in checkboxes (all unchecked by default): include navigation context (page, theme, viewport, app version, OS), include recent error logs, identify with your Maximus account. Routed through a Rust-side command so nothing is sent unless you press *Send* (#67)
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
### Changed
- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96)
## [0.8.0] - 2026-04-14
### Added
- **Reports hub**: `/reports` is now a hub surfacing a live highlights panel (current month + YTD net balance with sparklines, top movers vs. last month, top recent transactions) and four navigation cards to dedicated sub-reports (#69#76)
- **Highlights report** (`/reports/highlights`): balance tiles with 12-month sparklines, sortable top movers table, diverging bar chart, recent transactions list with 30/60/90 day window toggle (#71)
- **Trends report** (`/reports/trends`): internal sub-view toggle between global flow (income vs. expenses) and by-category evolution, chart/table toggle on both (#72)
- **Compare report** (`/reports/compare`): tab bar for Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget; diverging bar chart centered on zero for the first two modes (#73)
- **Category zoom** (`/reports/category`): single-category drill-down with donut chart of subcategory breakdown, monthly evolution area chart, and filterable transactions table (#74)
- **Contextual keyword editing**: right-click any transaction row to add its description as a categorization keyword; a preview dialog shows every transaction that would be recategorized (capped at 50, with an opt-in checkbox for N+) before you confirm. Available on the category zoom, the highlights list, and the main transactions page (#74, #75)
- **Bookmarkable period**: the reports period now lives in the URL (`?from=YYYY-MM-DD&to=YYYY-MM-DD`), so you can copy, paste, and share the link and keep the same state (#70)
- **View mode preference** (chart vs. table) is now persisted in `localStorage` per report section
### Changed
- The legacy monolithic `useReports` hook has been split into per-domain hooks (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) so every sub-report owns only the state it needs (#70)
- Context menu on reports (right-click) is now a generic `ContextMenu` shell reused by the existing chart menu and the new keyword dialog (#69)
### Removed
- The dynamic pivot table report was removed. Over 90% of its real usage was zooming into a single category, which is better served by the new Category Zoom report. Git history preserves the old implementation if it ever needs to come back (#69)
### Security
- New `AddKeywordDialog` enforces a 264 character length bound on user keywords to prevent ReDoS on large transaction sets (CWE-1333), uses parameterized `LIKE` queries for the preview (CWE-89), wraps its INSERT + per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK transaction (CWE-662), renders all untrusted descriptions as React children (CWE-79), and recategorizes only the rows the user explicitly checked — never retroactively. Keyword reassignment across categories requires an explicit confirmation step (#74)
- `getCategoryZoom` walks the category tree through a **bounded** recursive CTE (`WHERE depth < 5`), protecting against malformed `parent_id` cycles (CWE-835) (#74)
## [0.7.4] - 2026-04-14
### Changed
- OAuth tokens are now stored in the OS keychain (Credential Manager on Windows, Secret Service on Linux) instead of a plaintext JSON file. Existing users are migrated transparently on the next sign-in refresh; the old file is zeroed and removed. A "tokens stored in plaintext fallback" banner appears in Settings if the keychain is unavailable (#66, #78, #79, #81)
- Cached account info is now HMAC-signed with a keychain-stored key: writing `subscription_status` to `account.json` manually can no longer bypass the Premium gate (#80)
- PIN hashing migrated from SHA-256 to Argon2id for brute-force resistance (CWE-916). Existing SHA-256 PINs are verified transparently and rehashed on next successful unlock; new PINs use Argon2id (#54)
### Security
- Closed CWE-312 (cleartext storage of OAuth tokens), CWE-345 (missing integrity check on the subscription cache), and CWE-916 (weak PIN hashing). Legacy `tokens.json` and legacy unsigned `account.json` caches are rejected by the gating path until the next token refresh re-establishes a keychain-anchored trust (#66, #54)
## [0.7.3] - 2026-04-13
### Fixed
- Maximus Account sign-in: the deep-link callback now uses the canonical Tauri v2 `on_open_url` API, so the auth code is properly received by the running app instead of leaving the UI stuck in "loading" (#51, #65)
- OAuth callbacks containing an `error` parameter now surface the error to the UI instead of being silently ignored (#51)
## [0.7.2] - 2026-04-13
### Changed
- Auto-updates are temporarily open to the Free edition until the license server (issue #49) is live. Gating will be restored once paid activation works end-to-end (#48)
## [0.7.1] - 2026-04-13
### Fixed
- Maximus Account sign-in: the OAuth2 callback now correctly returns to the running app instead of launching a second instance and leaving the original one stuck in "loading" (#51, #65)
## [0.7.0] - 2026-04-11
### Added ### Added
- CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60) - CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60)
- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
- Maximus Account card in Settings: optional sign-in via OAuth2 PKCE for Premium features (#51)
- Machine activation: activate/deactivate machines against the license server, view activated machines in the license card (#53)
- Daily subscription status check: automatically refreshes account info once per day at launch (#51)
### Changed ### Changed
- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48) - Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48)
- Edition detection now considers Maximus Account subscription: Premium overrides Base when subscription is active (#51)
## [0.6.7] - 2026-03-29 ## [0.6.7] - 2026-03-29

View file

@ -1,127 +0,0 @@
# ADR-0006 : Stockage des tokens OAuth dans le trousseau OS
- **Date** : 2026-04-14
- **Statut** : Accepté
## Contexte
Depuis la v0.7.0, Simpl'Résultat utilise OAuth2 Authorization Code + PKCE pour authentifier les utilisateurs auprès de Logto (Compte Maximus). Les tokens résultants (`access_token`, `refresh_token`, `id_token`) étaient persistés dans `<app_data>/auth/tokens.json`, protégés uniquement par les permissions fichier (`0600` sous Unix, aucune ACL sous Windows).
Le refresh token donne une session longue durée. Le laisser en clair expose l'utilisateur à plusieurs classes d'attaques :
- Malware local tournant sous le même UID (lecture du home directory).
- Backups automatiques (home sync, backup tools) qui copient le fichier sans distinction.
- Shell non-root obtenu par l'attaquant.
- Sous Windows, absence de protection ACL rendait le fichier lisible par n'importe quel process utilisateur.
De plus, avant cette ADR, `account.json` était également en clair et son champ `subscription_status` servait au gating licence (Premium). Écrire manuellement `{"subscription_status": "active"}` dans ce fichier contournait le paywall.
## Options considérées
### Option 1 — Trousseau OS via `keyring` crate (RETENUE)
Librairie Rust `keyring` (v3.6) qui expose une API unifiée au-dessus des trousseaux natifs :
- **Windows** : Credential Manager (Win32 API, toujours présent)
- **Linux** : Secret Service via D-Bus (gnome-keyring, kwallet, keepassxc)
- **macOS** : Keychain Services (hors cible actuelle)
**Avantages** :
- Le système d'exploitation gère la clé maître (session utilisateur).
- Pas de mot de passe supplémentaire à demander à l'utilisateur.
- Support multi-plateforme natif.
**Inconvénients** :
- Sur Linux, requiert un service Secret Service actif (D-Bus + keyring daemon). Sur une session headless ou un CI sans D-Bus, il faut un fallback.
- Dépendance de build supplémentaire (`libdbus-1-dev`).
### Option 2 — `tauri-plugin-stronghold`
Chiffrement au repos avec une master password déverrouillée au démarrage.
**Rejeté** parce que :
- Casse l'UX de connexion silencieuse (refresh automatique au démarrage).
- Ajoute une saisie de passphrase à chaque lancement.
- Surface de UX plus large qu'un simple fallback keychain.
### Option 3 — Chiffrement AES-256-GCM custom avec clé dérivée du PIN
**Rejeté** parce que :
- Seulement applicable aux profils protégés par PIN (minorité).
- Les tokens OAuth doivent être lus sans interaction (refresh silencieux), donc aucune clé à demander.
- Simplement déplace le problème de la clé maître.
## Décision
**Trousseau OS via `keyring` crate, avec fallback fichier supervisé.**
### Architecture
Nouveau module `src-tauri/src/commands/token_store.rs` qui centralise le stockage :
- `save(app, &StoredTokens)` — tente le keychain, retombe sur `tokens.json` chiffré par permissions (`0600` Unix) si keychain indisponible.
- `load(app) -> Option<StoredTokens>` — lit depuis le keychain, migre un `tokens.json` résiduel à la volée.
- `delete(app)` — efface les deux stores (idempotent).
- `store_mode(app) -> Option<StoreMode>` — exposé au frontend pour afficher une bannière quand le fallback est actif.
### Choix du backend `keyring`
Le crate `keyring` v3 demande la sélection explicite des backends :
- **Linux** : `sync-secret-service` + `crypto-rust` → passe par le crate `dbus-secret-service` → crate `dbus` → lib C `libdbus-1`. Requiert `libdbus-1-dev` au build time et `libdbus-1-3` au runtime (ce dernier est universellement présent sur les distributions desktop Linux).
- **Windows** : `windows-native` → bind direct sur `windows-sys`, pas de dépendance externe.
L'option `async-secret-service` (via `zbus`, pur Rust) a été envisagée pour éviter la dépendance `libdbus-1-dev`, mais elle force une API asynchrone sur toutes les plateformes, ce qui ne matche pas le design sync de `license_commands::current_edition` (appelé depuis `check_entitlement`, sync). Le compromis accepté : un paquet apt de plus dans la CI, une API sync partout.
### Identité du trousseau
`service = "com.simpl.resultat"` (identifiant canonique de l'app dans `tauri.conf.json`), `user = "oauth-tokens"`. Ce choix aligne l'entrée keychain avec l'identité installée de l'app pour que les outils de management de credentials du système puissent la scoper correctement.
### Garde anti-downgrade
Un flag persistant `store_mode` (valeurs `keychain` ou `file`) est écrit dans `<app_data>/auth/store_mode` après chaque opération. Une fois qu'un `store_mode = keychain` a été enregistré, toute tentative ultérieure de sauvegarde qui échoue sur le keychain retourne une erreur au lieu de silenter-dégrader vers le fichier. Cela empêche un attaquant local de forcer la dégradation en bloquant temporairement D-Bus pour capturer les tokens en clair au prochain refresh.
### Migration transparente
Au premier `load()` après upgrade depuis v0.7.x, le module détecte `tokens.json` résiduel, copie son contenu dans le keychain, puis **overwrite le fichier avec des zéros + `fsync()` avant `remove_file()`**. C'est un mitigation best-effort contre la récupération des bits sur unallocated sectors (CWE-212). Pas un substitut à un disk encryption : les backups antérieurs à la migration conservent évidemment le vieux fichier. Documenté dans le CHANGELOG comme recommandation de rotation de session post-upgrade pour les utilisateurs inquiets.
### Scope : `tokens.json` migré, `account.json` signé
`account.json` **n'est pas** migré dans le keychain pour limiter le blast radius du changement et garder `write_restricted()` en place pour les fichiers non-sensibles. Toutefois, le champ `subscription_status` de ce cache servait au gating de licence Premium, ce qui créait un trou de tampering : un malware local pouvait écrire `"active"` dans le cache pour bypass le paywall sans jamais toucher le keychain.
**Corrigé via un second module `account_cache.rs`** : le cache est désormais encapsulé dans un wrapper `{"data": {...}, "sig": "<HMAC-SHA256>"}`. La clé HMAC 32 bytes est stockée dans le keychain (`user = "account-hmac-key"`), parallèlement aux tokens. Le chemin de gating (`license_commands::check_account_edition`) appelle `account_cache::load_verified` qui refuse tout payload non-signé ou avec signature invalide, et **fail-closed** (retourne None → Premium reste gated) si la clé HMAC est inaccessible.
Le chemin d'affichage UI (`get_account_info` → `load_unverified`) accepte encore les anciens payloads non-signés pour que les utilisateurs upgradés voient leur profil immédiatement. La distinction display/verified est explicite dans l'API.
## Conséquences
### Positives
- **Protection cryptographique native** : sous Windows, les tokens sont maintenant protégés par Credential Manager (DPAPI sous le capot). Sous Linux, par le keyring daemon avec une master password de session.
- **Anti-tampering du gating licence** : écrire `account.json` ne débloque plus Premium.
- **Fail-closed par défaut** : tous les chemins qui échouent sur le keychain retournent des erreurs au lieu de dégrader silencieusement.
- **Migration transparente** : zéro action utilisateur pour les upgrades depuis v0.7.x.
- **Anti-downgrade** : un attaquant ne peut pas forcer la dégradation vers le fichier pour capturer les tokens au prochain refresh.
### Négatives
- **Dépendance Linux** : `libdbus-1-dev` est requis au build time (ajouté à `check.yml` et `release.yml`). Au runtime, `libdbus-1-3` est déjà présent sur toutes les distros desktop, mais une session headless sans D-Bus déclenche le fallback fichier (signalé à l'utilisateur par la bannière Settings).
- **Surface d'attaque supply-chain accrue** : `keyring` + `dbus-secret-service` + `zbus` + `dbus` représentent une chaîne transitive nouvelle. Mitigé par un step `cargo audit` non-bloquant dans `check.yml`.
- **Logs plus verbeux** : chaque fallback imprime un warning sur stderr pour qu'un dev puisse diagnostiquer. Pas de télémétrie.
- **Sous Linux, la première utilisation peut demander un déverrouillage** : GNOME Keyring peut prompt l'utilisateur pour déverrouiller sa session keyring si elle ne l'est pas déjà. Ce comportement est natif du trousseau, pas de Simpl'Résultat.
### Tests
- Tests unitaires : 9 nouveaux tests (serde round-trip de `StoredTokens`, parse/encode de `StoreMode`, zéroïfication, HMAC sign/verify, tamper detection, wrong key, envelope serde).
- Tests manuels : matrice de 5 scénarios sur pop-os (Linux) et Windows, documentée dans l'issue #82.
- Pas de mock du keychain en CI : la matrice manuelle couvre les chemins où une lib externe est requise.
## Références
- Issue parente : maximus/simpl-resultat#66
- PRs : #83 (core), #84 (CI/packaging), #85 (subscription HMAC), #86 (UI banner), #87 (wrap-up)
- CWE-212 : Improper Removal of Sensitive Information Before Storage or Transfer
- CWE-312 : Cleartext Storage of Sensitive Information
- CWE-345 : Insufficient Verification of Data Authenticity
- CWE-757 : Selection of Less-Secure Algorithm During Negotiation
- [keyring crate v3.6 documentation](https://docs.rs/keyring/3.6.3/keyring/)
- [Secret Service API specification](https://specifications.freedesktop.org/secret-service-spec/latest/)

View file

@ -1,95 +0,0 @@
# ADR 0007 — Reports hub refactor
- Status: Accepted
- Date: 2026-04-14
- Milestone: `spec-refonte-rapports`
## Context
The original `/reports` page exposed five tabs (`trends`, `byCategory`, `overTime`, `budgetVsActual`, `dynamic`) as independent analytic views backed by a single monolithic `useReports` hook. Three problems built up over time:
1. **No narrative.** None of the tabs answered "what's important to know about my finances this month?". Users had to navigate several tabs and reconstruct the story themselves.
2. **Oversized pivot.** The dynamic pivot table (`DynamicReport*`) was powerful but complex. In practice ~90 % of its actual usage boiled down to zooming into a single category. It added visual and cognitive debt without proportional value.
3. **Disconnected classification.** Keywords could only be edited from `/categories`. Spotting a mis-classified transaction in a report meant leaving the report, editing a rule, and navigating back — a context break that discouraged hygiene.
## Decision
Refactor `/reports` into a **hub + four dedicated sub-routes**, wired to a shared bookmarkable period and per-domain hooks, with contextual keyword editing via right-click.
### Routing
```
/reports → hub (highlights panel + nav cards)
/reports/highlights → detailed highlights
/reports/trends → global flow + by-category evolution
/reports/compare → month vs month / year vs year / actual vs budget
/reports/category → single-category zoom with rollup
```
All pages share the reporting period through the URL query string (`?from=YYYY-MM-DD&to=YYYY-MM-DD&period=...`), resolved by a pure `resolveReportsPeriod()` helper. Default: current civil year. The query string approach is deliberately **not** a React context — it keeps the URL bookmarkable and stays consistent with the rest of the project, which does not use global React contexts for UI state.
### Per-domain hooks
The monolithic `useReports` was split into:
| Hook | Responsibility |
|------|----------------|
| `useReportsPeriod` | Read/write period via `useSearchParams` |
| `useHighlights` | Fetch highlights snapshot + window-days state |
| `useTrends` | Fetch global or by-category trends depending on sub-view |
| `useCompare` | Fetch MoM / YoY; budget mode delegates to `BudgetVsActualTable` |
| `useCategoryZoom` | Fetch zoom data with rollup toggle |
Each page mounts only the hook it needs; no hook carries state for reports the user is not currently viewing.
### Dynamic pivot removal
Removed outright rather than hidden behind a feature flag. A runtime flag would leave `getDynamicReportData` and its dynamic `FIELD_SQL` in the shipped bundle as a dead-but-live attack surface (OWASP A05:2021). Git history preserves the previous implementation if it ever needs to come back.
### Contextual keyword editing
Right-clicking a transaction row anywhere transaction-level (category zoom, highlights top transactions, main transactions page) opens an `AddKeywordDialog` that:
1. Validates the keyword is 264 characters after trim (anti-ReDoS, CWE-1333).
2. Previews matching transactions via a parameterised `LIKE $1` query, then filters in memory with the existing `buildKeywordRegex` helper (anti-SQL-injection, CWE-89).
3. Caps the visible preview at 50 rows; an explicit opt-in checkbox lets the user extend the apply to N50 non-displayed matches.
4. Runs INSERT (or UPDATE-reassign) + per-transaction UPDATEs inside a single SQL transaction (`BEGIN`/`COMMIT`/`ROLLBACK`), so a crash mid-apply can never leave a keyword orphaned from its transactions (CWE-662).
5. Renders transaction descriptions as React children — never `dangerouslySetInnerHTML` — with CSS-only truncation (CWE-79).
6. Recategorises only the rows the user explicitly checked; never retroactive on the entire history.
Reassigning an existing keyword across categories requires an explicit confirmation step and leaves the existing keyword's historical matches alone.
### Category zoom cycle guard
`getCategoryZoom` aggregates via a **bounded** recursive CTE (`WITH RECURSIVE ... WHERE ct.depth < 5`) so a corrupted `parent_id` loop (A B A) can never spin forever (CWE-835). A unit test with a canned cyclic fixture asserts termination.
## Consequences
### Positive
- Reports now tell a story ("what moved") before offering analytic depth.
- Each sub-route is independently code-splittable and testable.
- Period state is bookmarkable and shareable (copy URL → same view).
- Keyword hygiene happens inside the report, with a preview that's impossible in the old flow.
- The dialog's security guarantees are covered by 13 vitest cases (validation boundaries, parameterised LIKE, regex word-boundary filter, BEGIN/COMMIT wrap, ROLLBACK on failure, reassignment policy).
- The cycle guard is covered by its own test with the depth assertion.
### Negative / trade-offs
- Adds five new hooks and ~10 new components. Cognitive surface goes up but each piece is smaller and single-purpose.
- Aggregate tables in the compare and highlights sections intentionally skip the right-click menu (the row represents a category/month, not a transaction, so "add as keyword" is meaningless there). Users looking for consistency may be briefly confused.
- Right-clicking inside the main transactions page now offers two ways to add a keyword: the existing inline Tag button (no preview) and the new contextual dialog (with preview). Documented as complementary — the inline path is for quick manual classification, the dialog for preview-backed rule authoring.
## Alternatives considered
- **Keep the five-tab layout and only improve the pivot.** Rejected — it doesn't fix the "no narrative" issue and leaves the oversized pivot problem.
- **Hide the pivot behind a feature flag.** Rejected — the code stays in the bundle, runtime flag cannot be tree-shaken, and the i18n `reports.pivot.*` keys would have to linger indefinitely. Outright removal with git as the escape hatch was cheaper and cleaner.
- **React context for the shared period.** Rejected — the project does not use global React contexts for UI state. Query-string persistence is simpler, bookmarkable, and consistent with the rest of the codebase.
- **A single `ContextMenu` implementation shared across reports and charts.** Chose to generalise the existing `ChartContextMenu` into a `ContextMenu` shell; `ChartContextMenu` now composes the shared shell. Avoids duplicating click-outside + Escape handling.
## References
- Spec: `spec-refonte-rapports.md`
- Issues: #69 (foundation), #70 (hooks), #71 (highlights + hub), #72 (trends), #73 (compare), #74 (category zoom + AddKeywordDialog), #75 (right-click propagation), #76 (polish)
- OWASP A03:2021 (injection), A05:2021 (security misconfiguration)
- CWE-79 (XSS), CWE-89 (SQL injection), CWE-662 (improper synchronization), CWE-835 (infinite loop), CWE-1333 (ReDoS)

View file

@ -1,6 +1,6 @@
# Architecture technique — Simpl'Résultat # Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-04-13 — Version 0.7.3 > Document mis à jour le 2026-03-07 — Version 0.6.3
## Stack technique ## Stack technique
@ -26,7 +26,7 @@
``` ```
simpl-resultat/ simpl-resultat/
├── src/ # Frontend React/TypeScript ├── src/ # Frontend React/TypeScript
│ ├── components/ # 58 composants organisés par domaine │ ├── components/ # 55 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants │ │ ├── adjustments/ # 3 composants
│ │ ├── budget/ # 5 composants │ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants │ │ ├── categories/ # 5 composants
@ -34,13 +34,13 @@ simpl-resultat/
│ │ ├── import/ # 13 composants (wizard d'import) │ │ ├── import/ # 13 composants (wizard d'import)
│ │ ├── layout/ # AppShell, Sidebar │ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie) │ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard) │ │ ├── settings/ # 3 composants (+ LogViewerCard)
│ │ ├── shared/ # 6 composants réutilisables │ │ ├── shared/ # 6 composants réutilisables
│ │ └── transactions/ # 5 composants │ │ └── transactions/ # 5 composants
│ ├── contexts/ # ProfileContext (état global profil) │ ├── contexts/ # ProfileContext (état global profil)
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine) │ ├── hooks/ # 12 hooks custom (useReducer)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── pages/ # 10 pages
│ ├── services/ # 14 services métier │ ├── services/ # 14 services métier
│ ├── shared/ # Types et constantes partagés │ ├── shared/ # Types et constantes partagés
│ ├── utils/ # 4 utilitaires (parsing, CSV, charts) │ ├── utils/ # 4 utilitaires (parsing, CSV, charts)
@ -49,13 +49,10 @@ simpl-resultat/
│ └── main.tsx # Point d'entrée │ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust ├── src-tauri/ # Backend Rust
│ ├── src/ │ ├── src/
│ │ ├── commands/ # 6 modules de commandes Tauri │ │ ├── commands/ # 3 modules de commandes Tauri
│ │ │ ├── fs_commands.rs │ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs │ │ │ ├── export_import_commands.rs
│ │ │ ├── profile_commands.rs │ │ │ └── profile_commands.rs
│ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs
│ │ │ └── entitlements.rs
│ │ ├── database/ # Schémas SQL et migrations │ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql │ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql │ │ │ ├── seed_categories.sql
@ -110,7 +107,7 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations). Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
## Services TypeScript (17) ## Services TypeScript (15)
| Service | Responsabilité | | Service | Responsabilité |
|---------|---------------| |---------|---------------|
@ -121,18 +118,16 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `importSourceService.ts` | Configuration des sources d'import | | `importSourceService.ts` | Configuration des sources d'import |
| `importedFileService.ts` | Suivi des fichiers importés | | `importedFileService.ts` | Suivi des fichiers importés |
| `importConfigTemplateService.ts` | Modèles de configuration d'import | | `importConfigTemplateService.ts` | Modèles de configuration d'import |
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) | | `categorizationService.ts` | Catégorisation automatique |
| `adjustmentService.ts` | Gestion des ajustements | | `adjustmentService.ts` | Gestion des ajustements |
| `budgetService.ts` | Gestion budgétaire | | `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord | | `dashboardService.ts` | Agrégation données tableau de bord |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) | | `reportService.ts` | Génération de rapports et analytique |
| `dataExportService.ts` | Export de données (chiffré) | | `dataExportService.ts` | Export de données (chiffré) |
| `userPreferenceService.ts` | Stockage préférences utilisateur | | `userPreferenceService.ts` | Stockage préférences utilisateur |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
## Hooks (14) ## Hooks (12)
Chaque hook encapsule la logique d'état via `useReducer` : Chaque hook encapsule la logique d'état via `useReducer` :
@ -146,19 +141,12 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord | | `useDashboard` | Métriques du tableau de bord |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) | | `useReports` | Données analytiques |
| `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
| `useDataExport` | Export de données | | `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre | | `useTheme` | Thème clair/sombre |
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) | | `useUpdater` | Mise à jour de l'application |
| `useLicense` | État de la licence et entitlements |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
## Commandes Tauri (35) ## Commandes Tauri (18)
### `fs_commands.rs` — Système de fichiers (6) ### `fs_commands.rs` — Système de fichiers (6)
@ -183,87 +171,10 @@ Chaque hook encapsule la logique d'état via `useReducer` :
- `save_profiles` — Sauvegarde de la configuration - `save_profiles` — Sauvegarde de la configuration
- `delete_profile_db` — Suppression du fichier de base de données - `delete_profile_db` — Suppression du fichier de base de données
- `get_new_profile_init_sql` — Récupération du schéma consolidé - `get_new_profile_init_sql` — Récupération du schéma consolidé
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`) - `hash_pin` — Hachage Argon2 du PIN
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité) - `verify_pin` — Vérification du PIN
- `repair_migrations` — Réparation des checksums de migration (rusqlite) - `repair_migrations` — Réparation des checksums de migration (rusqlite)
### `license_commands.rs` — Licence et activation machine (10)
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
- `store_license` — Stockage de la clé dans le répertoire app data
- `store_activation_token` — Stockage du token d'activation
- `read_license` — Lecture de la licence stockée
- `get_edition` — Détection de l'édition active (free/base/premium)
- `get_machine_id` — Génération d'un identifiant machine unique
- `activate_machine` — Activation en ligne (appel API serveur de licences, issue #49)
- `deactivate_machine` — Désactivation d'une machine enregistrée
- `list_activated_machines` — Liste des machines activées pour la licence
- `get_activation_status` — État d'activation de la machine courante
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
- `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto
- `refresh_auth_token` — Rafraîchit l'access token via le refresh token
- `get_account_info` — Lecture du cache d'affichage (via `account_cache::load_unverified`, accepte les payloads legacy)
- `check_subscription_status` — Vérifie l'abonnement (max 1×/jour, fallback cache gracieux). Déclenche aussi la migration `tokens.json` → keychain via `token_store::load`
- `logout` — Efface tokens (`token_store`) + cache signé (`account_cache`) + clé HMAC du keychain
Note : `handle_auth_callback` n'est PAS exposée comme commande — elle est appelée depuis le handler deep-link `on_open_url` dans `lib.rs`. Voir section "OAuth2 et deep-link" plus bas.
### `token_store.rs` — Stockage des tokens OAuth (1)
- `get_token_store_mode` — Retourne `"keychain"`, `"file"` ou `null`. Utilisé par la bannière de sécurité `TokenStoreFallbackBanner` dans Settings pour alerter l'utilisateur quand les tokens sont dans le fallback fichier.
Module non-command : `save`, `load`, `delete`, `store_mode` — toute la logique de persistance passe par ce module, `auth_commands.rs` ne touche jamais directement `tokens.json`. Voir l'ADR 0006 pour la conception complète.
### `account_cache.rs` — Cache d'abonnement signé (aucune commande)
Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`. Expose :
- `save(app, &AccountInfo)` — écrit l'enveloppe signée `{data, sig}` dans `account.json`, avec clé HMAC-SHA256 stockée dans le keychain.
- `load_unverified(app)` — lecture pour affichage UI (accepte legacy et signé).
- `load_verified(app)` — lecture pour gating licence (refuse legacy, tampering, absence de clé). Utilisé par `license_commands::check_account_edition`.
- `delete(app)` — efface le fichier et la clé HMAC du keychain.
### `entitlements.rs` — Entitlements (1)
- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live
## Plugins Tauri
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
| Plugin | Rôle | Contrainte |
|--------|------|-----------|
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
| `tauri-plugin-process` | Relaunch après mise à jour | — |
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` |
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
## OAuth2 et deep-link (Compte Maximus)
Flow complet (v0.7.3+) :
1. Frontend appelle `start_oauth` → génère un code verifier PKCE (64 chars), le stocke dans `OAuthState` (Mutex en mémoire du processus), retourne l'URL Logto
2. Frontend ouvre l'URL via `tauri-plugin-opener` → le navigateur système affiche la page Logto
3. L'utilisateur s'authentifie (ou Logto auto-consent si session existante) → redirection 303 vers `simpl-resultat://auth/callback?code=...`
4. L'OS route le custom scheme vers une nouvelle instance de l'app → `tauri-plugin-single-instance` (feature `deep-link`) détecte l'instance existante, **ne démarre PAS un nouveau processus**, et forwarde l'URL à l'instance vivante
5. Le callback `app.deep_link().on_open_url(...)` enregistré via `DeepLinkExt` reçoit les URLs. Pour chaque URL :
- Si un param `code` est présent → appelle `handle_auth_callback` (token exchange vers `/oidc/token`, fetch `/oidc/me`, écriture des tokens via `token_store::save` (keychain OS, fallback fichier 0600) + cache signé via `account_cache::save` (HMAC-SHA256), émission de l'event `auth-callback-success`)
- Si un param `error` est présent → émission de l'event `auth-callback-error` avec `error: error_description`
6. Le hook `useAuth` (frontend) écoute `auth-callback-success` / `auth-callback-error` et met à jour l'état
Pourquoi cet enchaînement est critique :
- **Sans `tauri-plugin-single-instance`** : une nouvelle instance démarre à chaque callback, le `OAuthState` est vide (pas de verifier), le token exchange échoue
- **Sans `on_open_url`** : l'ancien listener `app.listen("deep-link://new-url", ...)` ne recevait pas les URLs forwardées par single-instance. L'API canonique v2 via `DeepLinkExt` est nécessaire
- **Sans gestion des erreurs** : un callback `?error=...` laissait l'UI bloquée en état "loading" infini
Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_commands.rs` (PKCE + token exchange), `src-tauri/src/commands/token_store.rs` (persistance keychain + fallback), `src-tauri/src/commands/account_cache.rs` (cache signé HMAC), `src/hooks/useAuth.ts` (frontend), `src/components/settings/TokenStoreFallbackBanner.tsx` (UI de l'état dégradé).
## Pages et routing ## Pages et routing
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate). Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
@ -285,12 +196,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/categories` | `CategoriesPage` | Gestion hiérarchique | | `/categories` | `CategoriesPage` | Gestion hiérarchique |
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels | | `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
| `/budget` | `BudgetPage` | Planification budgétaire | | `/budget` | `BudgetPage` | Planification budgétaire |
| `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 4 cartes de navigation | | `/reports` | `ReportsPage` | Analytique et rapports |
| `/reports/highlights` | `ReportsHighlightsPage` | Faits saillants détaillés (soldes, top mouvements, top transactions) |
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
| `/settings` | `SettingsPage` | Paramètres | | `/settings` | `SettingsPage` | Paramètres |
| `/docs` | `DocsPage` | Documentation in-app | | `/docs` | `DocsPage` | Documentation in-app |
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) | | `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
@ -307,24 +213,12 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
## CI/CD ## CI/CD
Deux workflows Forgejo Actions (avec miroir GitHub) dans `.forgejo/workflows/` : Workflow GitHub Actions (`release.yml`) déclenché par les tags `v*` :
### `check.yml` — Vérifications sur branches et PR
Déclenché sur chaque push de branche (sauf `main`) et chaque PR vers `main`. Lance en parallèle :
- `cargo check` + `cargo test` (Rust)
- `npm run build` (tsc + vite)
- `npm test` (vitest)
Doit être vert avant tout merge. Évite de découvrir des régressions au moment du tag de release.
### `release.yml` — Build et publication
Déclenché par les tags `v*`. Deux jobs :
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS) 1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
2. **build-linux** (ubuntu-22.04) → `.deb` + `.rpm` 2. **build-linux** (ubuntu-22.04) → `.deb` + `.AppImage`
Fonctionnalités : Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY) - Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` - JSON d'updater pour mises à jour automatiques
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md - Release GitHub automatique avec notes d'installation

View file

@ -246,56 +246,51 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
## 9. Rapports ## 9. Rapports
`/reports` est un **hub** qui répond à quatre questions : *qu'est-ce qui a bougé ce mois ?*, *où je vais sur 12 mois ?*, *comment je me situe vs période précédente ou vs budget ?*, *que se passe-t-il dans cette catégorie ?* Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.
### Le hub ### Fonctionnalités
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, quatre cartes mènent aux quatre sous-rapports dédiés. - Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en barres empilées), avec filtre par type (dépense/revenu/transfert)
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
- Détail des transactions par catégorie avec tri par colonne (date, description, montant)
- Toggle pour afficher ou masquer les montants dans le détail des transactions
Le sélecteur de période en haut à droite est **partagé** entre toutes les pages via l'URL : `?from=YYYY-MM-DD&to=YYYY-MM-DD`. Copiez l'URL pour revenir plus tard au même état ou la partager. ### Comment faire
### Rapport Faits saillants (`/reports/highlights`) 1. Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel
2. Ajustez la période avec le sélecteur de période
- Tuiles de soldes mois courant + YTD avec sparklines 12 mois 3. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
- Tableau triable des **top mouvements** (catégories avec la plus forte variation vs mois précédent), ou graphique en barres divergentes centré sur zéro (toggle graphique/tableau) 4. Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher
- Liste des **plus grosses transactions récentes** avec fenêtre configurable 30 / 60 / 90 jours 5. Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel
6. Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions
### Rapport Tendances (`/reports/trends`) 7. Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants
- **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau
- **Par catégorie** : évolution de chaque catégorie, en lignes ou tableau pivot
### Rapport Comparables (`/reports/compare`)
Trois modes accessibles via un tab bar :
- **Mois vs mois précédent** — tableau catégories × 2 colonnes + écart $ et %
- **Année vs année précédente** — même logique sur 12 mois vs 12 mois
- **Réel vs budget** — reprend la vue Budget vs Réel avec ses totaux mensuels et cumul annuel
### Rapport Zoom catégorie (`/reports/category`)
Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez :
- Un **donut chart** de la répartition par sous-catégorie avec le total au centre
- Un graphique d'évolution mensuelle de la catégorie
- Un tableau triable des transactions
### Édition contextuelle des mots-clés
**Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite)
2. Un sélecteur de catégorie cible
3. Un bouton **Appliquer et recatégoriser**
L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées.
### Astuces ### Astuces
- Le toggle **graphique / tableau** est mémorisé par sous-rapport (vos préférences restent même après redémarrage) - Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser
- Les mots-clés doivent faire entre 2 et 64 caractères (protection contre les regex explosives) - Le sélecteur de période s'applique à tous les onglets de graphiques simultanément
- Le zoom catégorie est **protégé contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app - Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie
- Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques
### Rapport dynamique
Le rapport dynamique fonctionne comme un tableau croisé dynamique (pivot table). Vous composez votre propre rapport en assignant des dimensions et des mesures.
**Dimensions disponibles :** Année, Mois, Type (dépense/revenu/transfert), Catégorie Niveau 1 (parent), Catégorie Niveau 2 (enfant).
**Mesures :** Montant périodique (somme), Cumul annuel (YTD).
1. Cliquez sur un champ disponible dans le panneau de droite
2. Choisissez où le placer : Lignes, Colonnes, Filtres ou Valeurs
3. Le tableau et/ou le graphique se mettent à jour automatiquement
4. Utilisez les filtres pour restreindre les données (ex : Type = dépense uniquement)
5. Basculez entre les vues Tableau, Graphique ou Les deux
6. Cliquez sur le X pour retirer un champ d'une zone
--- ---
@ -309,7 +304,6 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Guide d'utilisation complet accessible directement depuis les paramètres - Guide d'utilisation complet accessible directement depuis les paramètres
- Vérification automatique des mises à jour avec installation en un clic - Vérification automatique des mises à jour avec installation en un clic
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement - Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi)
- 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
@ -319,10 +313,9 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète 1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible 2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez 3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut 4. 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 5. 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 6. 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
### Astuces ### Astuces
@ -331,5 +324,4 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Exportez régulièrement pour garder une sauvegarde de vos données - Exportez régulièrement pour garder une sauvegarde de vos données
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer - Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page - Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique - En cas de problème, copiez les journaux et joignez-les à votre signalement
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement

11
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.7.3", "version": "0.6.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.7.3", "version": "0.6.6",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -3297,11 +3297,10 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

View file

@ -1,7 +1,7 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.8.2", "version": "0.6.7",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {

841
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "simpl-result" name = "simpl-result"
version = "0.8.2" version = "0.6.7"
description = "Personal finance management app" description = "Personal finance management app"
license = "GPL-3.0-only" license = "GPL-3.0-only"
authors = ["you"] authors = ["you"]
@ -25,8 +25,6 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
libsqlite3-sys = { version = "0.30", features = ["bundled"] } libsqlite3-sys = { version = "0.30", features = ["bundled"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@ -36,23 +34,9 @@ encoding_rs = "0.8"
walkdir = "2" walkdir = "2"
aes-gcm = "0.10" aes-gcm = "0.10"
argon2 = "0.5" argon2 = "0.5"
subtle = "2"
rand = "0.8" rand = "0.8"
jsonwebtoken = "9" jsonwebtoken = "9"
machine-uid = "0.5" machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["macros"] }
hostname = "0.4"
urlencoding = "2"
base64 = "0.22"
# OAuth token storage in OS keychain (Credential Manager on Windows,
# Secret Service on Linux). We use sync-secret-service to get sync
# methods that are safe to call from async Tauri commands without
# tokio runtime entanglement. Requires libdbus-1-dev at build time
# on Linux (libdbus-1-3 is present on every desktop Linux at runtime).
keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] }
zeroize = "1"
hmac = "0.12"
[dev-dependencies] [dev-dependencies]
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem` # Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`

View file

@ -1,292 +0,0 @@
// Integrity-protected cache for cached account info.
//
// The user's subscription tier is used by `license_commands::current_edition`
// to unlock Premium features. Until this module, the subscription_status
// claim was read directly from a plaintext `account.json` on disk, which
// meant any local process (malware, nosy user, curl) could write
// `{"subscription_status": "active"}` and bypass the paywall without
// ever touching the Logto session. This module closes that trap.
//
// Approach: an HMAC-SHA256 signature is computed over the serialized
// AccountInfo bytes using a per-install key stored in the OS keychain
// (via the `keyring` crate, same backend as token_store). The signed
// payload is wrapped as `{"data": {...}, "sig": "<base64>"}`. On read,
// verification requires the same key; tampering with either `data` or
// `sig` invalidates the cache.
//
// Trust chain:
// - Key lives in the keychain, scoped to service "com.simpl.resultat",
// user "account-hmac-key". Compromising it requires compromising the
// keychain, which is the existing trust boundary for OAuth tokens.
// - A legacy unsigned `account.json` (from v0.7.x) is still readable
// for display purposes (email, name, picture), but the gating path
// uses `load_verified` which returns None for legacy payloads —
// Premium features stay locked until the next token refresh rewrites
// the file with a signature.
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::fs;
use super::auth_commands::AccountInfo;
use super::token_store::{auth_dir, write_restricted};
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
const KEYCHAIN_USER_HMAC: &str = "account-hmac-key";
const ACCOUNT_FILE: &str = "account.json";
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Serialize, Deserialize)]
struct SignedAccount {
data: AccountInfo,
sig: String,
}
/// Read the HMAC key from the keychain, creating a fresh random key on
/// first use. The key is never persisted to disk — if the keychain is
/// unreachable, the whole cache signing/verification path falls back
/// to "not signed" which means gating stays locked until the keychain
/// is available again. This is intentional (fail-closed).
fn get_or_create_key() -> Result<[u8; 32], String> {
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC)
.map_err(|e| format!("Keychain entry init failed: {}", e))?;
match entry.get_password() {
Ok(b64) => decode_key(&b64),
Err(keyring::Error::NoEntry) => {
use rand::RngCore;
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
let encoded = encode_key(&key);
entry
.set_password(&encoded)
.map_err(|e| format!("Keychain HMAC key write failed: {}", e))?;
Ok(key)
}
Err(e) => Err(format!("Keychain HMAC key read failed: {}", e)),
}
}
fn encode_key(key: &[u8]) -> String {
use base64::{engine::general_purpose::STANDARD, Engine};
STANDARD.encode(key)
}
fn decode_key(raw: &str) -> Result<[u8; 32], String> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD
.decode(raw.trim())
.map_err(|e| format!("Invalid HMAC key encoding: {}", e))?;
if bytes.len() != 32 {
return Err(format!(
"HMAC key must be 32 bytes, got {}",
bytes.len()
));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(arr)
}
fn sign(key: &[u8; 32], payload: &[u8]) -> String {
use base64::{engine::general_purpose::STANDARD, Engine};
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take any key length");
mac.update(payload);
STANDARD.encode(mac.finalize().into_bytes())
}
fn verify(key: &[u8; 32], payload: &[u8], sig_b64: &str) -> bool {
use base64::{engine::general_purpose::STANDARD, Engine};
let Ok(sig_bytes) = STANDARD.decode(sig_b64) else {
return false;
};
let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
return false;
};
mac.update(payload);
mac.verify_slice(&sig_bytes).is_ok()
}
/// Write the account cache as `{"data": {...}, "sig": "..."}`. The key
/// is fetched from (or created in) the keychain. Writes fall back to an
/// unsigned legacy-shape payload only when the keychain is unreachable
/// — this keeps the UI functional on keychain-less systems but means
/// the gating path will refuse to grant Premium until the keychain
/// comes back.
pub fn save(app: &tauri::AppHandle, account: &AccountInfo) -> Result<(), String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
match get_or_create_key() {
Ok(key) => {
// Serialise the AccountInfo alone (compact, deterministic
// for a given struct layout), sign the bytes, then re-wrap
// into the signed envelope. We write the signed envelope
// as pretty-printed JSON for readability in the audit log.
let data_bytes =
serde_json::to_vec(account).map_err(|e| format!("Serialize error: {}", e))?;
let sig = sign(&key, &data_bytes);
let envelope = SignedAccount {
data: account.clone(),
sig,
};
let json = serde_json::to_string_pretty(&envelope)
.map_err(|e| format!("Serialize envelope error: {}", e))?;
write_restricted(&path, &json)
}
Err(err) => {
eprintln!(
"account_cache: keychain HMAC key unavailable, writing unsigned legacy payload ({})",
err
);
// Fallback: unsigned payload. UI still works, but
// `load_verified` will reject this file for gating.
let json = serde_json::to_string_pretty(account)
.map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&path, &json)
}
}
}
/// Load the cached account for **display purposes**. Accepts both the
/// new signed envelope and legacy plaintext AccountInfo files. Does
/// NOT verify the signature — suitable for showing the user's name /
/// email / picture in the UI, but never for gating decisions.
pub fn load_unverified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
// Prefer the signed envelope shape; fall back to legacy flat
// AccountInfo so upgraded users see their account info immediately
// rather than a blank card until the next token refresh.
if let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) {
return Ok(Some(envelope.data));
}
if let Ok(flat) = serde_json::from_str::<AccountInfo>(&raw) {
return Ok(Some(flat));
}
Err("Invalid account cache payload".to_string())
}
/// Load the cached account and verify the HMAC signature. Used by the
/// license gating path (`current_edition`). Returns Ok(None) when:
/// - no cache exists,
/// - the cache is in legacy unsigned shape (pre-v0.8 or post-fallback),
/// - the keychain HMAC key is unreachable,
/// - the signature does not verify.
///
/// Any of these states must cause Premium features to stay locked —
/// never accept an unverifiable payload for a gating decision.
pub fn load_verified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
// Only signed envelopes are acceptable here. Legacy flat payloads
// are treated as "no verified account".
let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) else {
return Ok(None);
};
let Ok(key) = get_or_create_key() else {
return Ok(None);
};
let data_bytes = match serde_json::to_vec(&envelope.data) {
Ok(v) => v,
Err(_) => return Ok(None),
};
if !verify(&key, &data_bytes, &envelope.sig) {
return Ok(None);
}
Ok(Some(envelope.data))
}
/// Delete the cached account file AND the keychain HMAC key. Called on
/// logout so the next login generates a fresh key bound to the new
/// session.
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if path.exists() {
let _ = fs::remove_file(&path);
}
if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC) {
let _ = entry.delete_credential();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_account() -> AccountInfo {
AccountInfo {
email: "user@example.com".into(),
name: Some("Test User".into()),
picture: None,
subscription_status: Some("active".into()),
}
}
#[test]
fn sign_then_verify_same_key() {
let key = [7u8; 32];
let payload = b"hello world";
let sig = sign(&key, payload);
assert!(verify(&key, payload, &sig));
}
#[test]
fn verify_rejects_tampered_payload() {
let key = [7u8; 32];
let sig = sign(&key, b"original");
assert!(!verify(&key, b"tampered", &sig));
}
#[test]
fn verify_rejects_wrong_key() {
let key_a = [7u8; 32];
let key_b = [8u8; 32];
let sig = sign(&key_a, b"payload");
assert!(!verify(&key_b, b"payload", &sig));
}
#[test]
fn envelope_roundtrip_serde() {
let account = sample_account();
let key = [3u8; 32];
let data = serde_json::to_vec(&account).unwrap();
let sig = sign(&key, &data);
let env = SignedAccount {
data: account.clone(),
sig,
};
let json = serde_json::to_string(&env).unwrap();
let decoded: SignedAccount = serde_json::from_str(&json).unwrap();
let decoded_data = serde_json::to_vec(&decoded.data).unwrap();
assert!(verify(&key, &decoded_data, &decoded.sig));
}
#[test]
fn encode_decode_key_roundtrip() {
let key = [42u8; 32];
let encoded = encode_key(&key);
let decoded = decode_key(&encoded).unwrap();
assert_eq!(decoded, key);
}
#[test]
fn decode_key_rejects_wrong_length() {
use base64::{engine::general_purpose::STANDARD, Engine};
let short = STANDARD.encode([1u8; 16]);
assert!(decode_key(&short).is_err());
}
}

View file

@ -1,323 +0,0 @@
// OAuth2 PKCE flow for Compte Maximus (Logto) integration.
//
// Architecture:
// - The desktop app is registered as a "Native App" in Logto (public
// client, no secret).
// - OAuth2 Authorization Code + PKCE flow via the system browser.
// - Deep-link callback: simpl-resultat://auth/callback?code=...
// - Tokens are persisted through `token_store` which prefers the OS
// keychain (Credential Manager / Secret Service) and falls back to a
// restricted file only when no prior keychain success has been
// recorded. See `token_store.rs` for details.
//
// The PKCE verifier is held in memory via Tauri managed state, so it
// cannot be intercepted by another process. It is cleared after the
// callback exchange.
use serde::{Deserialize, Serialize};
use std::fs;
use std::sync::Mutex;
use tauri::Manager;
use super::account_cache;
use super::token_store::{
self, auth_dir, chrono_now, write_restricted, StoredTokens,
};
// Logto endpoint — overridable via env var for development.
fn logto_endpoint() -> String {
std::env::var("LOGTO_ENDPOINT")
.unwrap_or_else(|_| "https://auth.lacompagniemaximus.com".to_string())
}
// Logto app ID for the desktop native app.
fn logto_app_id() -> String {
std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "sr-desktop-native".to_string())
}
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
const LAST_CHECK_FILE: &str = "last_check";
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
/// PKCE state held in memory during the OAuth2 flow.
pub struct OAuthState {
pub code_verifier: Mutex<Option<String>>,
}
/// Account info exposed to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountInfo {
pub email: String,
pub name: Option<String>,
pub picture: Option<String>,
pub subscription_status: Option<String>,
}
fn generate_pkce() -> (String, String) {
use rand::Rng;
let mut rng = rand::thread_rng();
let verifier: String = (0..64)
.map(|_| {
let idx = rng.gen_range(0..62);
let c = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx];
c as char
})
.collect();
use sha2::{Digest, Sha256};
let hash = Sha256::digest(verifier.as_bytes());
let challenge = base64_url_encode(&hash);
(verifier, challenge)
}
fn base64_url_encode(data: &[u8]) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
URL_SAFE_NO_PAD.encode(data)
}
/// Start the OAuth2 PKCE flow. Generates a code verifier/challenge, stores the verifier
/// in memory, and returns the authorization URL to open in the system browser.
#[tauri::command]
pub fn start_oauth(app: tauri::AppHandle) -> Result<String, String> {
let (verifier, challenge) = generate_pkce();
// Store verifier in managed state
let state = app.state::<OAuthState>();
*state.code_verifier.lock().map_err(|e| format!("Mutex poisoned: {}", e))? = Some(verifier);
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let url = format!(
"{}/oidc/auth?client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=openid%20profile%20email%20offline_access",
endpoint,
urlencoding::encode(&client_id),
urlencoding::encode(REDIRECT_URI),
urlencoding::encode(&challenge),
);
Ok(url)
}
/// Exchange the authorization code for tokens. Called from the deep-link callback handler.
#[tauri::command]
pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result<AccountInfo, String> {
let verifier = {
let state = app.state::<OAuthState>();
let verifier = state.code_verifier.lock().map_err(|e| format!("Mutex poisoned: {}", e))?.take();
verifier.ok_or("No pending OAuth flow (verifier missing)")?
};
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/oidc/token", endpoint))
.form(&[
("grant_type", "authorization_code"),
("client_id", &client_id),
("redirect_uri", REDIRECT_URI),
("code", &code),
("code_verifier", &verifier),
])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Token exchange failed: {}", e))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Token exchange error: {}", body));
}
let token_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid token response: {}", e))?;
let access_token = token_resp["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();
let refresh_token = token_resp["refresh_token"].as_str().map(|s| s.to_string());
let id_token = token_resp["id_token"].as_str().map(|s| s.to_string());
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let expires_at = chrono_now() + expires_in;
// Persist tokens through token_store (prefers keychain over file).
let tokens = StoredTokens {
access_token: access_token.clone(),
refresh_token,
id_token,
expires_at,
};
token_store::save(&app, &tokens)?;
// Fetch user info
let account = fetch_userinfo(&endpoint, &access_token).await?;
// Store account info with an HMAC signature so the license gating
// path can trust the cached subscription_status without re-calling
// Logto on every entitlement check.
account_cache::save(&app, &account)?;
Ok(account)
}
/// Refresh the access token using the stored refresh token.
#[tauri::command]
pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, String> {
let tokens = token_store::load(&app)?.ok_or_else(|| "Not authenticated".to_string())?;
let refresh_token = tokens
.refresh_token
.ok_or("No refresh token available")?;
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/oidc/token", endpoint))
.form(&[
("grant_type", "refresh_token"),
("client_id", &client_id),
("refresh_token", &refresh_token),
])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Token refresh failed: {}", e))?;
if !resp.status().is_success() {
// Clear stored tokens on refresh failure.
let _ = token_store::delete(&app);
let _ = account_cache::delete(&app);
return Err("Session expired, please sign in again".to_string());
}
let token_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid token response: {}", e))?;
let new_access = token_resp["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();
let new_refresh = token_resp["refresh_token"].as_str().map(|s| s.to_string());
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let new_tokens = StoredTokens {
access_token: new_access.clone(),
refresh_token: new_refresh.or(Some(refresh_token)),
id_token: token_resp["id_token"].as_str().map(|s| s.to_string()),
expires_at: chrono_now() + expires_in,
};
token_store::save(&app, &new_tokens)?;
let account = fetch_userinfo(&endpoint, &new_access).await?;
account_cache::save(&app, &account)?;
Ok(account)
}
/// Read cached account info without network call. Used for UI display
/// only — accepts both signed (v0.8+) and legacy (v0.7.x) payloads so
/// upgraded users still see their name/email immediately. The license
/// gating path uses `account_cache::load_verified` instead.
#[tauri::command]
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
account_cache::load_unverified(&app)
}
/// Log out: clear all stored tokens and account info, including the
/// HMAC key so the next session starts with a fresh cryptographic
/// anchor.
#[tauri::command]
pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
token_store::delete(&app)?;
account_cache::delete(&app)?;
Ok(())
}
/// Check subscription status if the last check was more than 24h ago.
/// Returns the refreshed account info, or the cached info if no check was needed.
/// Graceful: returns Ok(None) if not authenticated, silently skips on network errors.
#[tauri::command]
pub async fn check_subscription_status(
app: tauri::AppHandle,
) -> Result<Option<AccountInfo>, String> {
// Not authenticated — nothing to check. This also triggers migration
// from a legacy tokens.json file into the keychain when present,
// because token_store::load() performs the migration eagerly.
if token_store::load(&app)?.is_none() {
return Ok(None);
}
let dir = auth_dir(&app)?;
let last_check_path = dir.join(LAST_CHECK_FILE);
let now = chrono_now();
// Check if we need to verify (more than 24h since last check)
if last_check_path.exists() {
if let Ok(raw) = fs::read_to_string(&last_check_path) {
if let Ok(ts) = raw.trim().parse::<i64>() {
if now - ts < CHECK_INTERVAL_SECS {
// Recent check — return cached account info
return get_account_info(app);
}
}
}
}
// Try to refresh the token to get fresh subscription status
match refresh_auth_token(app.clone()).await {
Ok(account) => {
// Update last check timestamp
let _ = write_restricted(&last_check_path, &now.to_string());
Ok(Some(account))
}
Err(_) => {
// Network error or expired session — graceful degradation.
// Still update the timestamp to avoid hammering on every launch.
let _ = write_restricted(&last_check_path, &now.to_string());
get_account_info(app)
}
}
}
async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result<AccountInfo, String> {
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/oidc/me", endpoint))
.bearer_auth(access_token)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Userinfo fetch failed: {}", e))?;
if !resp.status().is_success() {
return Err("Cannot fetch user info".to_string());
}
let info: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid userinfo response: {}", e))?;
Ok(AccountInfo {
email: info["email"]
.as_str()
.unwrap_or_default()
.to_string(),
name: info["name"].as_str().map(|s| s.to_string()),
picture: info["picture"].as_str().map(|s| s.to_string()),
subscription_status: info["custom_data"]["subscription_status"]
.as_str()
.map(|s| s.to_string()),
})
}

View file

@ -12,9 +12,7 @@ pub const EDITION_PREMIUM: &str = "premium";
/// Maps feature name → list of editions allowed to use it. /// Maps feature name → list of editions allowed to use it.
/// A feature absent from this list is denied for all editions. /// A feature absent from this list is denied for all editions.
const FEATURE_TIERS: &[(&str, &[&str])] = &[ const FEATURE_TIERS: &[(&str, &[&str])] = &[
// auto-update is temporarily open to FREE until the license server (issue #49) ("auto-update", &[EDITION_BASE, EDITION_PREMIUM]),
// is live. Re-gate to [BASE, PREMIUM] once paid activation works end-to-end.
("auto-update", &[EDITION_FREE, EDITION_BASE, EDITION_PREMIUM]),
("web-sync", &[EDITION_PREMIUM]), ("web-sync", &[EDITION_PREMIUM]),
("cloud-backup", &[EDITION_PREMIUM]), ("cloud-backup", &[EDITION_PREMIUM]),
("advanced-reports", &[EDITION_PREMIUM]), ("advanced-reports", &[EDITION_PREMIUM]),
@ -40,9 +38,8 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn free_allows_auto_update_temporarily() { fn free_blocks_auto_update() {
// Temporary: auto-update is open to FREE until the license server is live. assert!(!is_feature_allowed("auto-update", EDITION_FREE));
assert!(is_feature_allowed("auto-update", EDITION_FREE));
} }
#[test] #[test]

View file

@ -1,159 +0,0 @@
// Feedback Hub client — forwards user-submitted feedback to the central
// feedback-api service. Routed through Rust (not direct fetch) so that:
// - CORS is bypassed (Tauri origin is not whitelisted server-side by design)
// - The exact payload leaving the machine is auditable in a single place
// - The pattern matches the other outbound calls (OAuth, license, updater)
//
// The feedback-api contract is documented in
// `la-compagnie-maximus/docs/feedback-hub-ops.md`. The server silently drops
// any context key outside its whitelist, so this module only sends the
// fields declared in `Context` below.
use serde::{Deserialize, Serialize};
use std::time::Duration;
fn feedback_endpoint() -> String {
std::env::var("FEEDBACK_HUB_URL")
.unwrap_or_else(|_| "https://feedback.lacompagniemaximus.com".to_string())
}
/// Context payload sent with a feedback submission. Keys MUST match the
/// server whitelist in `feedback-api/index.js` — unknown keys are dropped
/// silently. Each field is capped at 500 chars server-side.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FeedbackContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport: Option<String>,
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Serialize)]
struct FeedbackPayload<'a> {
app_id: &'a str,
content: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<FeedbackContext>,
}
#[derive(Debug, Serialize)]
pub struct FeedbackSuccess {
pub id: String,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
struct FeedbackResponse {
id: String,
created_at: String,
}
/// Return a composed User-Agent string for the context payload, e.g.
/// `"Simpl'Résultat/0.8.1 (linux)"`. Uses std::env::consts::OS so we don't
/// pull in an extra Tauri plugin just for this.
#[tauri::command]
pub fn get_feedback_user_agent(app: tauri::AppHandle) -> String {
let version = app.package_info().version.to_string();
let os = std::env::consts::OS;
format!("Simpl'Résultat/{} ({})", version, os)
}
/// Submit a feedback to the Feedback Hub. Error strings are stable codes
/// ("invalid", "rate_limit", "server_error", "network_error") that the
/// frontend maps to i18n messages.
#[tauri::command]
pub async fn send_feedback(
content: String,
user_id: Option<String>,
context: Option<FeedbackContext>,
) -> Result<FeedbackSuccess, String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("invalid".to_string());
}
let payload = FeedbackPayload {
app_id: "simpl-resultat",
content: trimmed,
user_id,
context,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|_| "network_error".to_string())?;
let url = format!("{}/api/feedback", feedback_endpoint());
let res = client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|_| "network_error".to_string())?;
match res.status().as_u16() {
201 => {
let body: FeedbackResponse = res
.json()
.await
.map_err(|_| "server_error".to_string())?;
Ok(FeedbackSuccess {
id: body.id,
created_at: body.created_at,
})
}
400 => Err("invalid".to_string()),
429 => Err("rate_limit".to_string()),
_ => Err("server_error".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_skips_none_fields() {
let ctx = FeedbackContext {
page: Some("/settings".to_string()),
locale: Some("fr".to_string()),
theme: None,
viewport: None,
user_agent: None,
timestamp: None,
};
let json = serde_json::to_value(&ctx).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 2);
assert!(obj.contains_key("page"));
assert!(obj.contains_key("locale"));
}
#[test]
fn context_serializes_user_agent_camelcase() {
let ctx = FeedbackContext {
user_agent: Some("Simpl'Résultat/0.8.1 (linux)".to_string()),
..Default::default()
};
let json = serde_json::to_string(&ctx).unwrap();
assert!(json.contains("\"userAgent\""));
assert!(!json.contains("\"user_agent\""));
}
#[tokio::test]
async fn empty_content_is_rejected_locally() {
let res = send_feedback(" \n\t".to_string(), None, None).await;
assert_eq!(res.unwrap_err(), "invalid");
}
}

View file

@ -22,10 +22,12 @@ use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
// Ed25519 public key for license verification. // Ed25519 public key for license verification.
// //
// Production key generated 2026-04-10. The corresponding private key lives ONLY // IMPORTANT: this PEM is a development placeholder taken from RFC 8410 §10.3 test vectors.
// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM. // The matching private key is publicly known, so any license signed with it offers no real
// protection. Replace this constant with the production public key before shipping a paid
// release. The corresponding private key MUST live only on the license server (Issue #49).
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\ const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\ MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
-----END PUBLIC KEY-----\n"; -----END PUBLIC KEY-----\n";
const LICENSE_FILE: &str = "license.key"; const LICENSE_FILE: &str = "license.key";
@ -222,16 +224,7 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any /// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
/// failure resolves to "free" so feature gates fail closed. /// failure resolves to "free" so feature gates fail closed.
///
/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String { pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
// Check Compte Maximus subscription first — Premium overrides Base
if let Some(edition) = check_account_edition(app) {
if edition == EDITION_PREMIUM {
return edition;
}
}
let Ok(path) = license_path(app) else { let Ok(path) = license_path(app) else {
return EDITION_FREE.to_string(); return EDITION_FREE.to_string();
}; };
@ -267,22 +260,6 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
info.edition info.edition
} }
/// Read the HMAC-verified account cache to check for an active Premium
/// subscription. Legacy unsigned caches (from v0.7.x) and tampered
/// payloads return None — Premium features stay locked until the user
/// re-authenticates or the next token refresh re-signs the cache.
///
/// This is intentional: before HMAC signing, any local process could
/// write `{"subscription_status": "active"}` to account.json and
/// bypass the paywall. Fail-closed is the correct posture here.
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
let account = super::account_cache::load_verified(app).ok().flatten()?;
match account.subscription_status.as_deref() {
Some("active") => Some(EDITION_PREMIUM.to_string()),
_ => None,
}
}
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall /// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
/// or hardware migration, in which case the user must re-activate (handled in Issue #53). /// or hardware migration, in which case the user must re-activate (handled in Issue #53).
#[tauri::command] #[tauri::command]
@ -294,202 +271,6 @@ fn machine_id_internal() -> Result<String, String> {
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e)) machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
} }
// License server API base URL. Overridable via SIMPL_API_URL env var for development.
fn api_base_url() -> String {
std::env::var("SIMPL_API_URL")
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}
/// Machine info returned by the license server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineInfo {
pub machine_id: String,
pub machine_name: Option<String>,
pub activated_at: String,
pub last_seen_at: String,
}
/// Activation status for display in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivationStatus {
pub is_activated: bool,
pub machine_id: String,
}
/// Activate this machine with the license server. Reads the stored license key, sends
/// the machine_id to the API, and stores the returned activation token.
#[tauri::command]
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Err("No license key stored".to_string());
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let machine_id = machine_id_internal()?;
let machine_name = hostname::get()
.ok()
.and_then(|h| h.into_string().ok());
let url = format!("{}/licenses/activate", api_base_url());
let client = reqwest::Client::new();
let mut body = serde_json::json!({
"license_key": license_key.trim(),
"machine_id": machine_id,
});
if let Some(name) = machine_name {
body["machine_name"] = serde_json::Value::String(name);
}
let resp = client
.post(&url)
.json(&body)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
let status = resp.status();
let resp_body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid server response: {}", e))?;
if !status.is_success() {
let error = resp_body["error"]
.as_str()
.unwrap_or("Activation failed");
return Err(error.to_string());
}
let token = resp_body["activation_token"]
.as_str()
.ok_or("Server did not return an activation token")?;
// store_activation_token validates the token against local machine_id before writing
store_activation_token(app, token.to_string())?;
Ok(())
}
/// Deactivate a machine on the license server, freeing a slot.
#[tauri::command]
pub async fn deactivate_machine(
app: tauri::AppHandle,
machine_id: String,
) -> Result<(), String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Err("No license key stored".to_string());
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let url = format!("{}/licenses/deactivate", api_base_url());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"license_key": license_key.trim(),
"machine_id": machine_id,
}))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
let status = resp.status();
if !status.is_success() {
let resp_body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::json!({"error": "Deactivation failed"}));
let error = resp_body["error"].as_str().unwrap_or("Deactivation failed");
return Err(error.to_string());
}
// If deactivating this machine, remove the local activation token
let local_id = machine_id_internal()?;
if machine_id == local_id {
let act_path = activation_path(&app)?;
if act_path.exists() {
let _ = fs::remove_file(&act_path);
}
}
Ok(())
}
/// List all machines currently activated for the stored license.
#[tauri::command]
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Ok(vec![]);
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let url = format!("{}/licenses/verify", api_base_url());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"license_key": license_key.trim(),
}))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
if !resp.status().is_success() {
return Err("Cannot verify license".to_string());
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid server response: {}", e))?;
// The verify endpoint returns machines in the response when valid
let machines = body["machines"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(machines)
}
/// Check the local activation status without contacting the server.
#[tauri::command]
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
let machine_id = machine_id_internal()?;
let act_path = activation_path(&app)?;
let is_activated = if act_path.exists() {
if let Ok(token) = fs::read_to_string(&act_path) {
if let Ok(decoding_key) = embedded_decoding_key() {
validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok()
} else {
false
}
} else {
false
}
} else {
false
};
Ok(ActivationStatus {
is_activated,
machine_id,
})
}
// === Tests ==================================================================================== // === Tests ====================================================================================
#[cfg(test)] #[cfg(test)]

View file

@ -1,18 +1,11 @@
pub mod account_cache;
pub mod auth_commands;
pub mod entitlements; pub mod entitlements;
pub mod export_import_commands; pub mod export_import_commands;
pub mod feedback_commands;
pub mod fs_commands; pub mod fs_commands;
pub mod license_commands; pub mod license_commands;
pub mod profile_commands; pub mod profile_commands;
pub mod token_store;
pub use auth_commands::*;
pub use entitlements::*; pub use entitlements::*;
pub use export_import_commands::*; pub use export_import_commands::*;
pub use feedback_commands::*;
pub use fs_commands::*; pub use fs_commands::*;
pub use license_commands::*; pub use license_commands::*;
pub use profile_commands::*; pub use profile_commands::*;
pub use token_store::*;

View file

@ -1,9 +1,7 @@
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha384}; use sha2::{Digest, Sha256, Sha384};
use std::fs; use std::fs;
use subtle::ConstantTimeEq;
use tauri::Manager; use tauri::Manager;
use crate::database; use crate::database;
@ -120,103 +118,44 @@ pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
]) ])
} }
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
const ARGON2_M_COST: u32 = 65536; // 64 MiB
const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 1;
const ARGON2_OUTPUT_LEN: usize = 32;
const ARGON2_SALT_LEN: usize = 16;
fn argon2_hash(pin: &str, salt: &[u8]) -> Result<Vec<u8>, String> {
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN))
.map_err(|e| format!("Argon2 params error: {}", e))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut hash = vec![0u8; ARGON2_OUTPUT_LEN];
argon2
.hash_password_into(pin.as_bytes(), salt, &mut hash)
.map_err(|e| format!("Argon2 hash error: {}", e))?;
Ok(hash)
}
#[tauri::command] #[tauri::command]
pub fn hash_pin(pin: String) -> Result<String, String> { pub fn hash_pin(pin: String) -> Result<String, String> {
let mut salt = [0u8; ARGON2_SALT_LEN]; let mut salt = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut salt); rand::rngs::OsRng.fill_bytes(&mut salt);
let salt_hex = hex_encode(&salt); let salt_hex = hex_encode(&salt);
let hash = argon2_hash(&pin, &salt)?;
let hash_hex = hex_encode(&hash);
// Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash"
Ok(format!("argon2id:{}:{}", salt_hex, hash_hex))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyPinResult {
pub valid: bool,
/// New Argon2id hash when a legacy SHA-256 PIN was successfully verified and re-hashed
pub rehashed: Option<String>,
}
#[tauri::command]
pub fn verify_pin(pin: String, stored_hash: String) -> Result<VerifyPinResult, String> {
// Argon2id format: "argon2id:salt_hex:hash_hex"
if let Some(rest) = stored_hash.strip_prefix("argon2id:") {
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() != 2 {
return Err("Invalid Argon2id hash format".to_string());
}
let salt = hex_decode(parts[0])?;
let expected_hash = hex_decode(parts[1])?;
let computed = argon2_hash(&pin, &salt)?;
let valid = computed.ct_eq(&expected_hash).into();
return Ok(VerifyPinResult { valid, rehashed: None });
}
// Legacy SHA-256 format: "salt_hex:hash_hex"
let parts: Vec<&str> = stored_hash.split(':').collect();
if parts.len() != 2 {
return Err("Invalid stored hash format".to_string());
}
let salt_hex = parts[0];
let expected_hash = hex_decode(parts[1])?;
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes()); hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes()); hasher.update(pin.as_bytes());
let result = hasher.finalize(); let result = hasher.finalize();
let hash_hex = hex_encode(&result);
let valid: bool = result.as_slice().ct_eq(&expected_hash).into(); // Store as "salt:hash"
Ok(format!("{}:{}", salt_hex, hash_hex))
}
if valid { #[tauri::command]
// Re-hash with Argon2id so this legacy PIN is upgraded. pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
// If rehash fails, still allow login — don't block the user. let parts: Vec<&str> = stored_hash.split(':').collect();
let rehashed = hash_pin(pin).ok(); if parts.len() != 2 {
Ok(VerifyPinResult { valid: true, rehashed }) return Err("Invalid stored hash format".to_string());
} else {
Ok(VerifyPinResult { valid: false, rehashed: None })
} }
let salt_hex = parts[0];
let expected_hash = parts[1];
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes());
let result = hasher.finalize();
let computed_hash = hex_encode(&result);
Ok(computed_hash == expected_hash)
} }
fn hex_encode(bytes: &[u8]) -> String { fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect() bytes.iter().map(|b| format!("{:02x}", b)).collect()
} }
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
return Err("Invalid hex string length".to_string());
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(|e| format!("Invalid hex character: {}", e))
})
.collect()
}
/// Repair migration checksums for a profile database. /// Repair migration checksums for a profile database.
/// Updates stored checksums to match current migration SQL, avoiding re-application /// Updates stored checksums to match current migration SQL, avoiding re-application
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords). /// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
@ -278,98 +217,3 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<b
Ok(repaired) Ok(repaired)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_pin_produces_argon2id_format() {
let hash = hash_pin("1234".to_string()).unwrap();
assert!(hash.starts_with("argon2id:"), "Hash should start with 'argon2id:' prefix");
let parts: Vec<&str> = hash.split(':').collect();
assert_eq!(parts.len(), 3, "Hash should have 3 parts: prefix:salt:hash");
assert_eq!(parts[1].len(), ARGON2_SALT_LEN * 2, "Salt should be {} hex chars", ARGON2_SALT_LEN * 2);
assert_eq!(parts[2].len(), ARGON2_OUTPUT_LEN * 2, "Hash should be {} hex chars", ARGON2_OUTPUT_LEN * 2);
}
#[test]
fn test_hash_pin_different_salts() {
let h1 = hash_pin("1234".to_string()).unwrap();
let h2 = hash_pin("1234".to_string()).unwrap();
assert_ne!(h1, h2, "Two hashes of the same PIN should use different salts");
}
#[test]
fn test_verify_argon2id_pin_correct() {
let hash = hash_pin("5678".to_string()).unwrap();
let result = verify_pin("5678".to_string(), hash).unwrap();
assert!(result.valid, "Correct PIN should verify");
assert!(result.rehashed.is_none(), "Argon2id PIN should not be rehashed");
}
#[test]
fn test_verify_argon2id_pin_wrong() {
let hash = hash_pin("5678".to_string()).unwrap();
let result = verify_pin("0000".to_string(), hash).unwrap();
assert!(!result.valid, "Wrong PIN should not verify");
assert!(result.rehashed.is_none());
}
#[test]
fn test_verify_legacy_sha256_correct_and_rehash() {
// Create a legacy SHA-256 hash: "salt_hex:sha256(salt_hex + pin)"
let salt_hex = "abcdef0123456789";
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(b"4321");
let hash_bytes = hasher.finalize();
let hash_hex = hex_encode(&hash_bytes);
let stored = format!("{}:{}", salt_hex, hash_hex);
let result = verify_pin("4321".to_string(), stored).unwrap();
assert!(result.valid, "Correct legacy PIN should verify");
assert!(result.rehashed.is_some(), "Legacy PIN should be rehashed to Argon2id");
// Verify the rehashed value is a valid Argon2id hash
let new_hash = result.rehashed.unwrap();
assert!(new_hash.starts_with("argon2id:"));
// Verify the rehashed value works for future verification
let result2 = verify_pin("4321".to_string(), new_hash).unwrap();
assert!(result2.valid, "Rehashed PIN should verify");
assert!(result2.rehashed.is_none(), "Already Argon2id, no rehash needed");
}
#[test]
fn test_verify_legacy_sha256_wrong() {
let salt_hex = "abcdef0123456789";
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(b"4321");
let hash_bytes = hasher.finalize();
let hash_hex = hex_encode(&hash_bytes);
let stored = format!("{}:{}", salt_hex, hash_hex);
let result = verify_pin("9999".to_string(), stored).unwrap();
assert!(!result.valid, "Wrong legacy PIN should not verify");
assert!(result.rehashed.is_none(), "Failed verification should not rehash");
}
#[test]
fn test_verify_invalid_format() {
let result = verify_pin("1234".to_string(), "invalid".to_string());
assert!(result.is_err(), "Single-part hash should fail");
let result = verify_pin("1234".to_string(), "argon2id:bad".to_string());
assert!(result.is_err(), "Argon2id with wrong part count should fail");
}
#[test]
fn test_hex_roundtrip() {
let original = vec![0u8, 127, 255, 1, 16];
let encoded = hex_encode(&original);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(original, decoded);
}
}

View file

@ -1,393 +0,0 @@
// OAuth token storage abstraction.
//
// This module centralises how OAuth2 tokens are persisted. It tries the OS
// keychain first (Credential Manager on Windows, Secret Service on Linux
// via libdbus) and falls back to a restricted file on disk if the keychain
// is unavailable.
//
// Security properties:
// - The keychain service name matches the Tauri bundle identifier
// (com.simpl.resultat) so OS tools and future macOS builds can scope
// credentials correctly.
// - A `store_mode` flag is persisted next to the fallback file. Once the
// keychain has been used successfully, the store refuses to silently
// downgrade to the file fallback: a subsequent keychain failure is
// surfaced as an error so the caller can force re-authentication
// instead of leaving the user with undetected plaintext tokens.
// - Migration from an existing `tokens.json` file zeros the file contents
// and fsyncs before unlinking, reducing the window where the refresh
// token is recoverable from unallocated disk blocks.
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::Manager;
use zeroize::Zeroize;
// Keychain identifiers. The service name matches tauri.conf.json's
// `identifier` so credentials are scoped to the real app identity.
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
const KEYCHAIN_USER_TOKENS: &str = "oauth-tokens";
pub(crate) const AUTH_DIR: &str = "auth";
const TOKENS_FILE: &str = "tokens.json";
const STORE_MODE_FILE: &str = "store_mode";
/// Where token material currently lives. Exposed via a Tauri command so
/// the frontend can display a security banner when the app has fallen
/// back to the file store.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StoreMode {
Keychain,
File,
}
impl StoreMode {
fn as_str(&self) -> &'static str {
match self {
StoreMode::Keychain => "keychain",
StoreMode::File => "file",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"keychain" => Some(StoreMode::Keychain),
"file" => Some(StoreMode::File),
_ => None,
}
}
}
/// Serialised OAuth token bundle. Owned by `token_store` because this is
/// the only module that should reach for the persisted bytes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub expires_at: i64,
}
/// Resolve `<app_data_dir>/auth/`, creating it if needed. Shared with
/// auth_commands.rs which still writes non-secret files (account info,
/// last-check timestamp) in the same directory.
pub(crate) fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?
.join(AUTH_DIR);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
}
Ok(dir)
}
/// Write a file with 0600 permissions on Unix. Windows has no cheap
/// equivalent here; callers that rely on this function for secrets should
/// treat the Windows path as a last-resort fallback and surface the
/// degraded state to the user (see StoreMode).
pub(crate) fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.write_all(contents.as_bytes())
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.sync_all()
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
}
#[cfg(not(unix))]
{
fs::write(path, contents)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
}
Ok(())
}
pub(crate) fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn read_store_mode(dir: &Path) -> Option<StoreMode> {
let path = dir.join(STORE_MODE_FILE);
let raw = fs::read_to_string(&path).ok()?;
StoreMode::parse(&raw)
}
fn write_store_mode(dir: &Path, mode: StoreMode) -> Result<(), String> {
write_restricted(&dir.join(STORE_MODE_FILE), mode.as_str())
}
/// Overwrite the file contents with zeros, fsync, then unlink. Best-effort
/// mitigation against recovery of the refresh token from unallocated
/// blocks on copy-on-write filesystems where a plain unlink leaves the
/// ciphertext recoverable. Not a substitute for proper disk encryption.
fn zero_and_delete(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let len = fs::metadata(path)
.map(|m| m.len() as usize)
.unwrap_or(0)
.max(512);
let mut zeros = vec![0u8; len];
{
let mut f = fs::OpenOptions::new()
.write(true)
.truncate(false)
.open(path)
.map_err(|e| format!("Cannot open {} for wipe: {}", path.display(), e))?;
f.write_all(&zeros)
.map_err(|e| format!("Cannot zero {}: {}", path.display(), e))?;
f.sync_all()
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
}
zeros.zeroize();
fs::remove_file(path).map_err(|e| format!("Cannot remove {}: {}", path.display(), e))
}
fn tokens_to_json(tokens: &StoredTokens) -> Result<String, String> {
serde_json::to_string(tokens).map_err(|e| format!("Serialize error: {}", e))
}
fn tokens_from_json(raw: &str) -> Result<StoredTokens, String> {
serde_json::from_str(raw).map_err(|e| format!("Invalid tokens payload: {}", e))
}
fn keychain_entry() -> Result<keyring::Entry, keyring::Error> {
keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_TOKENS)
}
fn keychain_save(json: &str) -> Result<(), keyring::Error> {
keychain_entry()?.set_password(json)
}
fn keychain_load() -> Result<Option<String>, keyring::Error> {
match keychain_entry()?.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e),
}
}
fn keychain_delete() -> Result<(), keyring::Error> {
match keychain_entry()?.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e),
}
}
/// Persist the current OAuth token bundle.
///
/// Tries the OS keychain first. If the keychain write fails AND the
/// persisted `store_mode` flag shows the keychain has worked before, the
/// caller receives an error instead of a silent downgrade — this
/// prevents a hostile local process from forcing the app into the
/// weaker file-fallback path. On a fresh install (no prior flag), the
/// fallback is allowed but recorded so subsequent calls know the app is
/// running in degraded mode.
pub fn save(app: &tauri::AppHandle, tokens: &StoredTokens) -> Result<(), String> {
let json = tokens_to_json(tokens)?;
let dir = auth_dir(app)?;
let previous_mode = read_store_mode(&dir);
match keychain_save(&json) {
Ok(()) => {
// Keychain succeeded. Clean up any residual file from a prior
// fallback or migration source so nothing stays readable.
let residual = dir.join(TOKENS_FILE);
if residual.exists() {
let _ = zero_and_delete(&residual);
}
write_store_mode(&dir, StoreMode::Keychain)?;
Ok(())
}
Err(err) => {
if previous_mode == Some(StoreMode::Keychain) {
// Refuse to downgrade after a prior success — surface the
// failure so the caller can force re-auth instead of
// silently leaking tokens to disk.
return Err(format!(
"Keychain unavailable after prior success — refusing to downgrade. \
Original error: {}",
err
));
}
eprintln!(
"token_store: keychain unavailable, falling back to file store ({})",
err
);
write_restricted(&dir.join(TOKENS_FILE), &json)?;
write_store_mode(&dir, StoreMode::File)?;
Ok(())
}
}
}
/// Load the current OAuth token bundle.
///
/// Tries the keychain first. If the keychain is empty but a legacy
/// `tokens.json` file exists, this is a first-run migration: the tokens
/// are copied into the keychain, the file is zeroed and unlinked, and
/// `store_mode` is updated. If the keychain itself is unreachable, the
/// function falls back to reading the file — unless the `store_mode`
/// flag indicates the keychain has worked before, in which case it
/// returns an error to force re-auth.
pub fn load(app: &tauri::AppHandle) -> Result<Option<StoredTokens>, String> {
let dir = auth_dir(app)?;
let previous_mode = read_store_mode(&dir);
let residual = dir.join(TOKENS_FILE);
match keychain_load() {
Ok(Some(raw)) => {
let tokens = tokens_from_json(&raw)?;
// Defensive: if a leftover file is still around (e.g. a prior
// crash between keychain write and file delete), clean it up.
if residual.exists() {
let _ = zero_and_delete(&residual);
}
if previous_mode != Some(StoreMode::Keychain) {
write_store_mode(&dir, StoreMode::Keychain)?;
}
Ok(Some(tokens))
}
Ok(None) => {
// Keychain reachable but empty. Migrate from a legacy file if
// one exists, otherwise report no stored session.
if residual.exists() {
let raw = fs::read_to_string(&residual)
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
let tokens = tokens_from_json(&raw)?;
// Push into keychain and wipe the file. If the keychain
// push fails here, we keep the file rather than losing
// the user's session.
match keychain_save(&raw) {
Ok(()) => {
let _ = zero_and_delete(&residual);
write_store_mode(&dir, StoreMode::Keychain)?;
}
Err(e) => {
eprintln!("token_store: migration to keychain failed ({})", e);
write_store_mode(&dir, StoreMode::File)?;
}
}
Ok(Some(tokens))
} else {
Ok(None)
}
}
Err(err) => {
if previous_mode == Some(StoreMode::Keychain) {
return Err(format!(
"Keychain unavailable after prior success: {}",
err
));
}
// No prior keychain success: honour the file fallback if any.
eprintln!(
"token_store: keychain unreachable, using file fallback ({})",
err
);
if residual.exists() {
let raw = fs::read_to_string(&residual)
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
write_store_mode(&dir, StoreMode::File)?;
Ok(Some(tokens_from_json(&raw)?))
} else {
Ok(None)
}
}
}
}
/// Delete the stored tokens from both the keychain and the file
/// fallback. Both deletions are best-effort and ignore "no entry"
/// errors to stay idempotent.
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
let dir = auth_dir(app)?;
if let Err(err) = keychain_delete() {
eprintln!("token_store: keychain delete failed ({})", err);
}
let residual = dir.join(TOKENS_FILE);
if residual.exists() {
let _ = zero_and_delete(&residual);
}
// Leave the store_mode flag alone: it still describes what the app
// should trust the next time `save` is called.
Ok(())
}
/// Current store mode, derived from the persisted flag. Returns `None`
/// if no tokens have ever been written (no flag file).
pub fn store_mode(app: &tauri::AppHandle) -> Result<Option<StoreMode>, String> {
let dir = auth_dir(app)?;
Ok(read_store_mode(&dir))
}
/// Tauri command: expose the current store mode to the frontend.
/// Returns `"keychain"`, `"file"`, or `null` if the app has no stored
/// session yet. Used by the settings UI to show a security banner when
/// the fallback is active.
#[tauri::command]
pub fn get_token_store_mode(app: tauri::AppHandle) -> Result<Option<String>, String> {
Ok(store_mode(&app)?.map(|m| m.as_str().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_mode_roundtrip() {
assert_eq!(StoreMode::parse("keychain"), Some(StoreMode::Keychain));
assert_eq!(StoreMode::parse("file"), Some(StoreMode::File));
assert_eq!(StoreMode::parse("other"), None);
assert_eq!(StoreMode::Keychain.as_str(), "keychain");
assert_eq!(StoreMode::File.as_str(), "file");
}
#[test]
fn stored_tokens_serde_roundtrip() {
let tokens = StoredTokens {
access_token: "at".into(),
refresh_token: Some("rt".into()),
id_token: Some("it".into()),
expires_at: 42,
};
let json = tokens_to_json(&tokens).unwrap();
let decoded = tokens_from_json(&json).unwrap();
assert_eq!(decoded.access_token, "at");
assert_eq!(decoded.refresh_token.as_deref(), Some("rt"));
assert_eq!(decoded.id_token.as_deref(), Some("it"));
assert_eq!(decoded.expires_at, 42);
}
#[test]
fn zero_and_delete_removes_file() {
use std::io::Write as _;
let tmp = std::env::temp_dir().join(format!(
"simpl-resultat-token-store-test-{}",
std::process::id()
));
let mut f = fs::File::create(&tmp).unwrap();
f.write_all(b"sensitive").unwrap();
drop(f);
assert!(tmp.exists());
zero_and_delete(&tmp).unwrap();
assert!(!tmp.exists());
}
}

View file

@ -1,9 +1,6 @@
mod commands; mod commands;
mod database; mod database;
use std::sync::Mutex;
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_sql::{Migration, MigrationKind}; use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -85,63 +82,12 @@ pub fn run() {
]; ];
tauri::Builder::default() tauri::Builder::default()
// Single-instance plugin MUST be registered first. With the `deep-link`
// feature, it forwards `simpl-resultat://` URLs to the running instance
// so the OAuth2 callback reaches the process that holds the PKCE verifier.
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}))
.manage(commands::auth_commands::OAuthState {
code_verifier: Mutex::new(None),
})
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| { .setup(|app| {
#[cfg(desktop)] #[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Register the custom scheme at runtime on Linux (the .desktop file
// handles it in prod, but register_all is a no-op there and required
// for AppImage/dev builds).
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
let _ = app.deep_link().register_all();
}
// Canonical Tauri v2 pattern: on_open_url fires for both initial-launch
// URLs and subsequent URLs forwarded by tauri-plugin-single-instance
// (with the `deep-link` feature).
let handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
let url_str = url.as_str();
let h = handle.clone();
if let Some(code) = extract_auth_code(url_str) {
tauri::async_runtime::spawn(async move {
match commands::handle_auth_callback(h.clone(), code).await {
Ok(account) => {
let _ = h.emit("auth-callback-success", &account);
}
Err(err) => {
let _ = h.emit("auth-callback-error", &err);
}
}
});
} else {
// No `code` param — likely an OAuth error response. Surface
// it to the frontend instead of leaving the UI stuck in
// "loading" forever.
let err_msg = extract_auth_error(url_str)
.unwrap_or_else(|| "OAuth callback did not include a code".to_string());
let _ = h.emit("auth-callback-error", &err_msg);
}
}
});
Ok(()) Ok(())
}) })
.plugin( .plugin(
@ -175,52 +121,7 @@ pub fn run() {
commands::get_edition, commands::get_edition,
commands::get_machine_id, commands::get_machine_id,
commands::check_entitlement, commands::check_entitlement,
commands::activate_machine,
commands::deactivate_machine,
commands::list_activated_machines,
commands::get_activation_status,
commands::start_oauth,
commands::refresh_auth_token,
commands::get_account_info,
commands::check_subscription_status,
commands::logout,
commands::get_token_store_mode,
commands::send_feedback,
commands::get_feedback_user_agent,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
/// Extract the `code` query parameter from a deep-link callback URL.
/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123")
fn extract_auth_code(url: &str) -> Option<String> {
extract_query_param(url, "code")
}
/// Extract an OAuth error description from a callback URL. Returns a
/// formatted string combining `error` and `error_description` when present.
fn extract_auth_error(url: &str) -> Option<String> {
let error = extract_query_param(url, "error")?;
match extract_query_param(url, "error_description") {
Some(desc) => Some(format!("{}: {}", error, desc)),
None => Some(error),
}
}
fn extract_query_param(url: &str, key: &str) -> Option<String> {
let url = url.trim();
if !url.starts_with("simpl-resultat://auth/callback") {
return None;
}
let query = url.split('?').nth(1)?;
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next()? == key {
return kv.next().map(|v| {
urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string())
});
}
}
None
}

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat", "productName": "Simpl Resultat",
"version": "0.8.2", "version": "0.6.7",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -18,12 +18,12 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com https://feedback.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:" "csp": null
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis", "deb", "rpm"], "targets": ["nsis", "deb", "rpm", "appimage"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@ -34,11 +34,6 @@
"createUpdaterArtifacts": true "createUpdaterArtifacts": true
}, },
"plugins": { "plugins": {
"deep-link": {
"desktop": {
"schemes": ["simpl-resultat"]
}
},
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
"endpoints": [ "endpoints": [

View file

@ -10,11 +10,6 @@ import CategoriesPage from "./pages/CategoriesPage";
import AdjustmentsPage from "./pages/AdjustmentsPage"; import AdjustmentsPage from "./pages/AdjustmentsPage";
import BudgetPage from "./pages/BudgetPage"; import BudgetPage from "./pages/BudgetPage";
import ReportsPage from "./pages/ReportsPage"; import ReportsPage from "./pages/ReportsPage";
import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import DocsPage from "./pages/DocsPage"; import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage"; import ChangelogPage from "./pages/ChangelogPage";
@ -106,11 +101,6 @@ export default function App() {
<Route path="/adjustments" element={<AdjustmentsPage />} /> <Route path="/adjustments" element={<AdjustmentsPage />} />
<Route path="/budget" element={<BudgetPage />} /> <Route path="/budget" element={<BudgetPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
<Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/docs" element={<DocsPage />} /> <Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} /> <Route path="/changelog" element={<ChangelogPage />} />

View file

@ -1,278 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
import {
KEYWORD_MAX_LENGTH,
KEYWORD_MIN_LENGTH,
KEYWORD_PREVIEW_LIMIT,
applyKeywordWithReassignment,
previewKeywordMatches,
validateKeyword,
} from "../../services/categorizationService";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
interface CategoryOption {
id: number;
name: string;
}
export interface AddKeywordDialogProps {
initialKeyword: string;
initialCategoryId?: number | null;
onClose: () => void;
onApplied?: () => void;
}
type PreviewState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; visible: RecentTransaction[]; totalMatches: number }
| { kind: "error"; message: string };
export default function AddKeywordDialog({
initialKeyword,
initialCategoryId = null,
onClose,
onApplied,
}: AddKeywordDialogProps) {
const { t } = useTranslation();
const [keyword, setKeyword] = useState(initialKeyword);
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId);
const [categories, setCategories] = useState<CategoryOption[]>([]);
const [checked, setChecked] = useState<Set<number>>(new Set());
const [applyToHidden, setApplyToHidden] = useState(false);
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [replacePrompt, setReplacePrompt] = useState<string | null>(null);
const [allowReplaceExisting, setAllowReplaceExisting] = useState(false);
const validation = useMemo(() => validateKeyword(keyword), [keyword]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name }))))
.catch(() => setCategories([]));
}, []);
useEffect(() => {
if (!validation.ok) {
setPreview({ kind: "idle" });
return;
}
let cancelled = false;
setPreview({ kind: "loading" });
previewKeywordMatches(validation.value, KEYWORD_PREVIEW_LIMIT)
.then(({ visible, totalMatches }) => {
if (cancelled) return;
setPreview({ kind: "ready", visible, totalMatches });
setChecked(new Set(visible.map((tx) => tx.id)));
})
.catch((e: unknown) => {
if (cancelled) return;
setPreview({ kind: "error", message: e instanceof Error ? e.message : String(e) });
});
return () => {
cancelled = true;
};
}, [validation]);
const toggleRow = (id: number) => {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const canApply =
validation.ok &&
categoryId !== null &&
preview.kind === "ready" &&
!isApplying &&
checked.size > 0;
const handleApply = async () => {
if (!validation.ok || categoryId === null) return;
setIsApplying(true);
setApplyError(null);
try {
await applyKeywordWithReassignment({
keyword: validation.value,
categoryId,
transactionIds: Array.from(checked),
allowReplaceExisting,
});
if (onApplied) onApplied();
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === "keyword_already_exists") {
setReplacePrompt(t("reports.keyword.alreadyExists", { category: "" }));
} else {
setApplyError(msg);
}
} finally {
setIsApplying(false);
}
};
const preventBackdropClose = (e: React.MouseEvent) => e.stopPropagation();
return (
<div
onClick={onClose}
className="fixed inset-0 z-[200] bg-black/40 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div
onClick={preventBackdropClose}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl max-w-xl w-full max-h-[90vh] flex flex-col"
>
<header className="px-5 py-3 border-b border-[var(--border)]">
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
</header>
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.keyword.dialogTitle")}
</span>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
maxLength={KEYWORD_MAX_LENGTH * 2 /* allow user to paste longer then see error */}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
/>
{!validation.ok && (
<span className="text-xs text-[var(--negative)]">
{validation.reason === "tooShort"
? t("reports.keyword.tooShort", { min: KEYWORD_MIN_LENGTH })
: t("reports.keyword.tooLong", { max: KEYWORD_MAX_LENGTH })}
</span>
)}
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.category")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<section>
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
{t("reports.keyword.willMatch")}
</h3>
{preview.kind === "idle" && (
<p className="text-sm text-[var(--muted-foreground)] italic"></p>
)}
{preview.kind === "loading" && (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("common.loading")}</p>
)}
{preview.kind === "error" && (
<p className="text-sm text-[var(--negative)]">{preview.message}</p>
)}
{preview.kind === "ready" && (
<>
<p className="text-sm mb-2">
{t("reports.keyword.nMatches", { count: preview.totalMatches })}
</p>
<ul className="divide-y divide-[var(--border)] max-h-[220px] overflow-y-auto border border-[var(--border)] rounded-lg">
{preview.visible.map((tx) => (
<li key={tx.id} className="flex items-center gap-2 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={checked.has(tx.id)}
onChange={() => toggleRow(tx.id)}
className="accent-[var(--primary)]"
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
<span className="tabular-nums font-medium flex-shrink-0">
{new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
}).format(tx.amount)}
</span>
</li>
))}
</ul>
{preview.totalMatches > preview.visible.length && (
<label className="flex items-center gap-2 text-xs mt-2 text-[var(--muted-foreground)]">
<input
type="checkbox"
checked={applyToHidden}
onChange={(e) => setApplyToHidden(e.target.checked)}
className="accent-[var(--primary)]"
/>
{t("reports.keyword.applyToHidden", {
count: preview.totalMatches - preview.visible.length,
})}
</label>
)}
</>
)}
</section>
{replacePrompt && (
<div className="bg-[var(--muted)]/50 border border-[var(--border)] rounded-lg p-3 text-sm flex flex-col gap-2">
<p>{replacePrompt}</p>
<button
type="button"
onClick={() => {
setAllowReplaceExisting(true);
setReplacePrompt(null);
void handleApply();
}}
className="self-start px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm"
>
{t("common.confirm")}
</button>
</div>
)}
{applyError && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-lg p-3 text-sm">
{applyError}
</div>
)}
</div>
<footer className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-2 rounded-lg text-sm bg-[var(--card)] border border-[var(--border)] hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleApply}
disabled={!canApply}
className="px-3 py-2 rounded-lg text-sm bg-[var(--primary)] text-white font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isApplying ? t("common.loading") : t("reports.keyword.applyAndRecategorize")}
</button>
</footer>
</div>
</div>
);
}

View file

@ -6,7 +6,7 @@ import { verifyPin } from "../../services/profileService";
interface Props { interface Props {
profileName: string; profileName: string;
storedHash: string; storedHash: string;
onSuccess: (rehashed?: string | null) => void; onSuccess: () => void;
onCancel: () => void; onCancel: () => void;
} }
@ -41,9 +41,9 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
if (value && filledCount === index + 1) { if (value && filledCount === index + 1) {
setChecking(true); setChecking(true);
try { try {
const result = await verifyPin(pin.replace(/\s/g, ""), storedHash); const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash);
if (result.valid) { if (valid) {
onSuccess(result.rehashed); onSuccess();
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) { } else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
setError(true); setError(true);
setDigits(["", "", "", "", "", ""]); setDigits(["", "", "", "", "", ""]);
@ -67,10 +67,10 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
const pin = digits.join(""); const pin = digits.join("");
if (pin.length >= 4) { if (pin.length >= 4) {
setChecking(true); setChecking(true);
verifyPin(pin, storedHash).then((result) => { verifyPin(pin, storedHash).then((valid) => {
setChecking(false); setChecking(false);
if (result.valid) { if (valid) {
onSuccess(result.rehashed); onSuccess();
} else { } else {
setError(true); setError(true);
setDigits(["", "", "", "", "", ""]); setDigits(["", "", "", "", "", ""]);

View file

@ -8,7 +8,7 @@ import type { Profile } from "../../services/profileService";
export default function ProfileSwitcher() { export default function ProfileSwitcher() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile(); const { profiles, activeProfile, switchProfile } = useProfile();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pinProfile, setPinProfile] = useState<Profile | null>(null); const [pinProfile, setPinProfile] = useState<Profile | null>(null);
const [showManage, setShowManage] = useState(false); const [showManage, setShowManage] = useState(false);
@ -36,15 +36,8 @@ export default function ProfileSwitcher() {
} }
}; };
const handlePinSuccess = async (rehashed?: string | null) => { const handlePinSuccess = () => {
if (pinProfile) { if (pinProfile) {
if (rehashed) {
try {
await updateProfile(pinProfile.id, { pin_hash: rehashed });
} catch {
// Best-effort rehash: don't block profile switch if persistence fails
}
}
switchProfile(pinProfile.id); switchProfile(pinProfile.id);
setPinProfile(null); setPinProfile(null);
} }

View file

@ -1,72 +0,0 @@
import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import type { CategoryZoomChild } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
export interface CategoryDonutChartProps {
byChild: CategoryZoomChild[];
centerLabel: string;
centerValue: string;
}
export default function CategoryDonutChart({
byChild,
centerLabel,
centerValue,
}: CategoryDonutChartProps) {
const { t } = useTranslation();
if (byChild.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 relative">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<ChartPatternDefs
prefix="cat-donut"
categories={byChild.map((c, index) => ({ color: c.categoryColor, index }))}
/>
<Pie
data={byChild}
dataKey="total"
nameKey="categoryName"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={95}
paddingAngle={2}
>
{byChild.map((entry, index) => (
<Cell
key={entry.categoryId}
fill={getPatternFill("cat-donut", index, entry.categoryColor)}
/>
))}
</Pie>
<Tooltip
formatter={(value) =>
typeof value === "number"
? new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(value)
: String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-xs text-[var(--muted-foreground)]">{centerLabel}</span>
<span className="text-lg font-bold">{centerValue}</span>
</div>
</div>
);
}

View file

@ -1,82 +0,0 @@
import { useTranslation } from "react-i18next";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { CategoryZoomEvolutionPoint } from "../../shared/types";
export interface CategoryEvolutionChartProps {
data: CategoryZoomEvolutionPoint[];
color?: string;
}
function formatMonth(month: string): string {
const [year, m] = month.split("-");
const date = new Date(Number(year), Number(m) - 1);
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
export default function CategoryEvolutionChart({
data,
color = "var(--primary)",
}: CategoryEvolutionChartProps) {
const { t, i18n } = useTranslation();
if (data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<h3 className="text-sm font-semibold mb-2">{t("reports.category.evolution")}</h3>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(v)
}
/>
<Tooltip
formatter={(value) =>
typeof value === "number"
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(value)
: String(value)
}
labelFormatter={(label) => (typeof label === "string" ? formatMonth(label) : String(label ?? ""))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Area type="monotone" dataKey="total" stroke={color} fill={color} fillOpacity={0.2} />
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,135 +0,0 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Tag } from "lucide-react";
import type { RecentTransaction } from "../../shared/types";
import ContextMenu from "../shared/ContextMenu";
export interface CategoryTransactionsTableProps {
transactions: RecentTransaction[];
onAddKeyword?: (transaction: RecentTransaction) => void;
}
type SortKey = "date" | "description" | "amount";
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function CategoryTransactionsTable({
transactions,
onAddKeyword,
}: CategoryTransactionsTableProps) {
const { t, i18n } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>("amount");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
const sorted = useMemo(() => {
const copy = [...transactions];
copy.sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "date":
cmp = a.date.localeCompare(b.date);
break;
case "description":
cmp = a.description.localeCompare(b.description);
break;
case "amount":
cmp = Math.abs(a.amount) - Math.abs(b.amount);
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
return copy;
}, [transactions, sortKey, sortDir]);
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir("desc");
}
}
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
if (!onAddKeyword) return;
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, tx });
};
const header = (key: SortKey, label: string, align: "left" | "right") => (
<th
onClick={() => toggleSort(key)}
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
>
{label}
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
</th>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-[var(--card)]">
<tr className="border-b border-[var(--border)]">
{header("date", t("transactions.date"), "left")}
{header("description", t("transactions.description"), "left")}
{header("amount", t("transactions.amount"), "right")}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={3} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
sorted.map((tx) => (
<tr
key={tx.id}
onContextMenu={(e) => handleContextMenu(e, tx)}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2 tabular-nums text-[var(--muted-foreground)]">{tx.date}</td>
<td className="px-3 py-2 truncate max-w-[280px]">{tx.description}</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)",
}}
>
{formatAmount(tx.amount, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{menu && onAddKeyword && (
<ContextMenu
x={menu.x}
y={menu.y}
header={menu.tx.description}
onClose={() => setMenu(null)}
items={[
{
icon: <Tag size={14} />,
label: t("reports.keyword.addFromTransaction"),
onClick: () => {
onAddKeyword(menu.tx);
},
},
]}
/>
)}
</div>
);
}

View file

@ -1,72 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
interface CategoryOption {
id: number;
name: string;
color: string | null;
parent_id: number | null;
}
export interface CategoryZoomHeaderProps {
categoryId: number | null;
includeSubcategories: boolean;
onCategoryChange: (id: number | null) => void;
onIncludeSubcategoriesChange: (flag: boolean) => void;
}
export default function CategoryZoomHeader({
categoryId,
includeSubcategories,
onCategoryChange,
onIncludeSubcategoriesChange,
}: CategoryZoomHeaderProps) {
const { t } = useTranslation();
const [categories, setCategories] = useState<CategoryOption[]>([]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) =>
setCategories(
rows.map((r) => ({ id: r.id, name: r.name, color: r.color, parent_id: r.parent_id })),
),
)
.catch(() => setCategories([]));
}, []);
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<label className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.category.selectCategory")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => onCategoryChange(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSubcategories}
onChange={(e) => onIncludeSubcategoriesChange(e.target.checked)}
className="accent-[var(--primary)]"
/>
<span>
{includeSubcategories
? t("reports.category.includeSubcategories")
: t("reports.category.directOnly")}
</span>
</label>
</div>
);
}

View file

@ -1,47 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import BudgetVsActualTable from "./BudgetVsActualTable";
import { getBudgetVsActualData } from "../../services/budgetService";
import type { BudgetVsActualRow } from "../../shared/types";
export interface CompareBudgetViewProps {
year: number;
month: number;
}
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) {
const { t } = useTranslation();
const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
getBudgetVsActualData(year, month)
.then((data) => {
if (!cancelled) setRows(data);
})
.catch((e: unknown) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, [year, month]);
if (error) {
return (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4">{error}</div>
);
}
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.bva.noData")}
</div>
);
}
return <BudgetVsActualTable data={rows} />;
}

View file

@ -1,37 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CompareMode } from "../../hooks/useCompare";
export interface CompareModeTabsProps {
value: CompareMode;
onChange: (mode: CompareMode) => void;
}
export default function CompareModeTabs({ value, onChange }: CompareModeTabsProps) {
const { t } = useTranslation();
const modes: { id: CompareMode; labelKey: string }[] = [
{ id: "actual", labelKey: "reports.compare.modeActual" },
{ id: "budget", labelKey: "reports.compare.modeBudget" },
];
return (
<div className="inline-flex gap-1" role="tablist">
{modes.map(({ id, labelKey }) => (
<button
key={id}
type="button"
role="tab"
onClick={() => onChange(id)}
aria-selected={value === id}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === id
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(labelKey)}
</button>
))}
</div>
);
}

View file

@ -1,111 +0,0 @@
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import type { CategoryDelta } from "../../shared/types";
export interface ComparePeriodChartProps {
rows: CategoryDelta[];
previousLabel: string;
currentLabel: string;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
export default function ComparePeriodChart({
rows,
previousLabel,
currentLabel,
}: ComparePeriodChartProps) {
const { t, i18n } = useTranslation();
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
// Sort by current-period amount (largest spending first) so the user's eye
// lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart.
const chartData = [...rows]
.sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({
name: r.categoryName,
previousAmount: r.previousAmount,
currentAmount: r.currentAmount,
color: r.categoryColor,
}));
const previousFill = "var(--muted-foreground)";
const currentFill = "var(--primary)";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={Math.max(280, chartData.length * 44 + 60)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
barCategoryGap="20%"
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
<XAxis
type="number"
tickFormatter={(v) => formatCurrency(v, i18n.language)}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis
type="category"
dataKey="name"
width={140}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<Bar
dataKey="previousAmount"
name={previousLabel}
fill={previousFill}
radius={[0, 4, 4, 0]}
/>
<Bar
dataKey="currentAmount"
name={currentLabel}
fill={currentFill}
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,118 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CategoryDelta } from "../../shared/types";
export interface ComparePeriodTableProps {
rows: CategoryDelta[];
previousLabel: string;
currentLabel: string;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
export default function ComparePeriodTable({
rows,
previousLabel,
currentLabel,
}: ComparePeriodTableProps) {
const { t, i18n } = useTranslation();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.category")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{previousLabel}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{currentLabel}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.variationAbs")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.variationPct")}
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2">
<span className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color:
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatSignedCurrency(row.deltaAbs, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums"
style={{
color:
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatPct(row.deltaPct, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,93 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export interface CompareReferenceMonthPickerProps {
year: number;
month: number;
onChange: (year: number, month: number) => void;
/** Number of recent months to show in the dropdown. Default: 24. */
monthCount?: number;
/** "today" override for tests. */
today?: Date;
}
interface Option {
value: string; // "YYYY-MM"
year: number;
month: number;
label: string;
}
function formatMonth(year: number, month: number, language: string): string {
const date = new Date(year, month - 1, 1);
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(date);
}
export default function CompareReferenceMonthPicker({
year,
month,
onChange,
monthCount = 24,
today = new Date(),
}: CompareReferenceMonthPickerProps) {
const { t, i18n } = useTranslation();
const options = useMemo<Option[]>(() => {
const list: Option[] = [];
let y = today.getFullYear();
let m = today.getMonth() + 1;
for (let i = 0; i < monthCount; i++) {
list.push({
value: `${y}-${String(m).padStart(2, "0")}`,
year: y,
month: m,
label: formatMonth(y, m, i18n.language),
});
m -= 1;
if (m === 0) {
m = 12;
y -= 1;
}
}
return list;
}, [today, monthCount, i18n.language]);
const currentValue = `${year}-${String(month).padStart(2, "0")}`;
const isKnown = options.some((o) => o.value === currentValue);
const displayOptions = isKnown
? options
: [
{
value: currentValue,
year,
month,
label: formatMonth(year, month, i18n.language),
},
...options,
];
return (
<label className="inline-flex items-center gap-2">
<span className="text-sm text-[var(--muted-foreground)]">
{t("reports.compare.referenceMonth")}
</span>
<select
value={currentValue}
onChange={(e) => {
const opt = displayOptions.find((o) => o.value === e.target.value);
if (opt) onChange(opt.year, opt.month);
}}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] px-3 py-2 text-sm hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
>
{displayOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}

View file

@ -1,40 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CompareSubMode } from "../../hooks/useCompare";
export interface CompareSubModeToggleProps {
value: CompareSubMode;
onChange: (subMode: CompareSubMode) => void;
}
export default function CompareSubModeToggle({ value, onChange }: CompareSubModeToggleProps) {
const { t } = useTranslation();
const items: { id: CompareSubMode; labelKey: string }[] = [
{ id: "mom", labelKey: "reports.compare.subModeMoM" },
{ id: "yoy", labelKey: "reports.compare.subModeYoY" },
];
return (
<div
className="inline-flex rounded-lg border border-[var(--border)] bg-[var(--card)] p-0.5"
role="group"
aria-label={t("reports.compare.subModeAria")}
>
{items.map(({ id, labelKey }) => (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-pressed={value === id}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
value === id
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(labelKey)}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,106 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Table, BarChart3, Columns, Maximize2, Minimize2 } from "lucide-react";
import type { PivotConfig, PivotResult } from "../../shared/types";
import DynamicReportPanel from "./DynamicReportPanel";
import DynamicReportTable from "./DynamicReportTable";
import DynamicReportChart from "./DynamicReportChart";
type ViewMode = "table" | "chart" | "both";
interface DynamicReportProps {
config: PivotConfig;
result: PivotResult;
onConfigChange: (config: PivotConfig) => void;
}
export default function DynamicReport({ config, result, onConfigChange }: DynamicReportProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [fullscreen, setFullscreen] = useState(false);
const toggleFullscreen = useCallback(() => setFullscreen((prev) => !prev), []);
// Escape key exits fullscreen
useEffect(() => {
if (!fullscreen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setFullscreen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [fullscreen]);
const hasConfig = (config.rows.length > 0 || config.columns.length > 0) && config.values.length > 0;
const viewButtons: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
{ mode: "table", icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
{ mode: "both", icon: <Columns size={14} />, label: t("reports.pivot.viewBoth") },
];
return (
<div
className={
fullscreen
? "fixed inset-0 z-50 bg-[var(--background)] overflow-auto p-6"
: ""
}
>
<div className="flex gap-4 items-start">
{/* Content area */}
<div className="flex-1 min-w-0 space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-1">
{hasConfig && viewButtons.map(({ mode, icon, label }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
mode === viewMode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
<div className="flex-1" />
<button
onClick={toggleFullscreen}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
title={fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
>
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
{fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
</button>
</div>
{/* Empty state */}
{!hasConfig && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-12 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noConfig")}
</div>
)}
{/* Table */}
{hasConfig && (viewMode === "table" || viewMode === "both") && (
<DynamicReportTable config={config} result={result} />
)}
{/* Chart */}
{hasConfig && (viewMode === "chart" || viewMode === "both") && (
<DynamicReportChart config={config} result={result} />
)}
</div>
{/* Side panel */}
<DynamicReportPanel
config={config}
onChange={onConfigChange}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Legend,
CartesianGrid,
} from "recharts";
import type { PivotConfig, PivotResult } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
// Generate distinct colors for series
const SERIES_COLORS = [
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
"#d946ef", "#0ea5e9", "#eab308", "#22c55e", "#e11d48",
];
interface DynamicReportChartProps {
config: PivotConfig;
result: PivotResult;
}
export default function DynamicReportChart({ config, result }: DynamicReportChartProps) {
const { t } = useTranslation();
const { chartData, seriesKeys, seriesColors } = useMemo(() => {
if (result.rows.length === 0) {
return { chartData: [], seriesKeys: [], seriesColors: {} };
}
const colDims = config.columns;
const rowDim = config.rows[0];
const measure = config.values[0] || "periodic";
// X-axis = composite column key (or first row dimension if no columns)
const hasColDims = colDims.length > 0;
if (!hasColDims && !rowDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
// Build composite column key per row
const getColKey = (r: typeof result.rows[0]) =>
colDims.map((d) => r.keys[d] || "").join(" — ");
// Series = first row dimension (or no stacking if no rows, or first row if columns exist)
const seriesDim = hasColDims ? rowDim : undefined;
// Collect unique x and series values
const xValues = hasColDims
? [...new Set(result.rows.map(getColKey))].sort()
: [...new Set(result.rows.map((r) => r.keys[rowDim]))].sort();
const seriesVals = seriesDim
? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort()
: [measure];
// Build chart data: one entry per x value
const data = xValues.map((xVal) => {
const entry: Record<string, string | number> = { name: xVal };
if (seriesDim) {
for (const sv of seriesVals) {
const matchingRows = result.rows.filter(
(r) => (hasColDims ? getColKey(r) : r.keys[rowDim]) === xVal && r.keys[seriesDim] === sv
);
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
} else {
const matchingRows = result.rows.filter((r) =>
hasColDims ? getColKey(r) === xVal : r.keys[rowDim] === xVal
);
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
return entry;
});
const colors: Record<string, string> = {};
seriesVals.forEach((sv, i) => {
colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length];
});
return { chartData: data, seriesKeys: seriesVals, seriesColors: colors };
}, [config, result]);
if (chartData.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("reports.pivot.noData")}</p>
</div>
);
}
const categoryEntries = seriesKeys.map((key, index) => ({
color: seriesColors[key],
index,
}));
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<ChartPatternDefs prefix="pivot-chart" categories={categoryEntries} />
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="name"
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
{seriesKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
stackId="stack"
fill={getPatternFill("pivot-chart", index, seriesColors[key])}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,306 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types";
import { getDynamicFilterValues } from "../../services/reportService";
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2", "level3"];
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
interface DynamicReportPanelProps {
config: PivotConfig;
onChange: (config: PivotConfig) => void;
}
export default function DynamicReportPanel({ config, onChange }: DynamicReportPanelProps) {
const { t } = useTranslation();
const [menuTarget, setMenuTarget] = useState<{ id: string; type: "field" | "measure"; x: number; y: number } | null>(null);
const [filterValues, setFilterValues] = useState<Record<string, string[]>>({});
const menuRef = useRef<HTMLDivElement>(null);
// A field is only "exhausted" if it's in all 3 zones (rows + columns + filters)
const inRows = new Set(config.rows);
const inColumns = new Set(config.columns);
const inFilters = new Set(Object.keys(config.filters) as PivotFieldId[]);
const assignedFields = new Set(
ALL_FIELDS.filter((f) => inRows.has(f) && inColumns.has(f) && inFilters.has(f))
);
const assignedMeasures = new Set(config.values);
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m));
// Load filter values when a field is added to filters
const filterFieldIds = Object.keys(config.filters) as PivotFieldId[];
useEffect(() => {
for (const fieldId of filterFieldIds) {
if (!filterValues[fieldId]) {
getDynamicFilterValues(fieldId as PivotFieldId).then((vals) => {
setFilterValues((prev) => ({ ...prev, [fieldId]: vals }));
});
}
}
}, [filterFieldIds.join(",")]);
// Close menu on outside click
useEffect(() => {
if (!menuTarget) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuTarget(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuTarget]);
const handleFieldClick = (id: string, type: "field" | "measure", e: React.MouseEvent) => {
setMenuTarget({ id, type, x: e.clientX, y: e.clientY });
};
const assignTo = useCallback((zone: PivotZone) => {
if (!menuTarget) return;
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
if (menuTarget.type === "measure") {
if (zone === "values") {
next.values = [...next.values, menuTarget.id as PivotMeasureId];
}
} else {
const fieldId = menuTarget.id as PivotFieldId;
if (zone === "rows") next.rows = [...next.rows, fieldId];
else if (zone === "columns") next.columns = [...next.columns, fieldId];
else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: { include: [], exclude: [] } };
}
setMenuTarget(null);
onChange(next);
}, [menuTarget, config, onChange]);
const removeFrom = (zone: PivotZone, id: string) => {
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
if (zone === "rows") next.rows = next.rows.filter((f) => f !== id);
else if (zone === "columns") next.columns = next.columns.filter((f) => f !== id);
else if (zone === "filters") {
const { [id]: _, ...rest } = next.filters;
next.filters = rest;
} else if (zone === "values") next.values = next.values.filter((m) => m !== id);
onChange(next);
};
const toggleFilterInclude = (fieldId: string, value: string) => {
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
const isIncluded = entry.include.includes(value);
const newInclude = isIncluded ? entry.include.filter((v) => v !== value) : [...entry.include, value];
// Remove from exclude if adding to include
const newExclude = isIncluded ? entry.exclude : entry.exclude.filter((v) => v !== value);
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
};
const toggleFilterExclude = (fieldId: string, value: string) => {
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
const isExcluded = entry.exclude.includes(value);
const newExclude = isExcluded ? entry.exclude.filter((v) => v !== value) : [...entry.exclude, value];
// Remove from include if adding to exclude
const newInclude = isExcluded ? entry.include : entry.include.filter((v) => v !== value);
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
};
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "level3" ? "level3" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
// Context menu only shows zones where the field is NOT already assigned
const getAvailableZones = (fieldId: string): PivotZone[] => {
const zones: PivotZone[] = [];
if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows");
if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns");
if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters");
return zones;
};
const zoneLabels: Record<PivotZone, string> = {
rows: t("reports.pivot.rows"),
columns: t("reports.pivot.columns"),
filters: t("reports.pivot.filters"),
values: t("reports.pivot.values"),
};
return (
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
{/* Available Fields */}
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-2">{t("reports.pivot.availableFields")}</h3>
<div className="flex flex-wrap gap-1.5">
{availableFields.map((f) => (
<button
key={f}
onClick={(e) => handleFieldClick(f, "field", e)}
className="px-2.5 py-1 rounded-lg bg-[var(--muted)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors text-xs"
>
{fieldLabel(f)}
</button>
))}
{availableMeasures.map((m) => (
<button
key={m}
onClick={(e) => handleFieldClick(m, "measure", e)}
className="px-2.5 py-1 rounded-lg bg-[var(--primary)]/10 text-[var(--primary)] hover:bg-[var(--primary)]/20 transition-colors text-xs"
>
{measureLabel(m)}
</button>
))}
{availableFields.length === 0 && availableMeasures.length === 0 && (
<span className="text-xs text-[var(--muted-foreground)]"></span>
)}
</div>
</div>
{/* Rows */}
<ZoneSection
label={t("reports.pivot.rows")}
items={config.rows}
getLabel={fieldLabel}
onRemove={(id) => removeFrom("rows", id)}
/>
{/* Columns */}
<ZoneSection
label={t("reports.pivot.columns")}
items={config.columns}
getLabel={fieldLabel}
onRemove={(id) => removeFrom("columns", id)}
/>
{/* Filters */}
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{t("reports.pivot.filters")}</h3>
{filterFieldIds.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="space-y-2">
{filterFieldIds.map((fieldId) => {
const entry = config.filters[fieldId] || { include: [], exclude: [] };
const hasActive = entry.include.length > 0 || entry.exclude.length > 0;
return (
<div key={fieldId}>
<div className="flex items-center gap-1 mb-1">
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
<button onClick={() => removeFrom("filters", fieldId)} className="text-[var(--muted-foreground)] hover:text-[var(--negative)]">
<X size={12} />
</button>
</div>
<div className="flex flex-wrap gap-1">
{(filterValues[fieldId] || []).map((val) => {
const isIncluded = entry.include.includes(val);
const isExcluded = entry.exclude.includes(val);
return (
<button
key={val}
onClick={() => toggleFilterInclude(fieldId, val)}
onContextMenu={(e) => {
e.preventDefault();
toggleFilterExclude(fieldId, val);
}}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
isIncluded
? "bg-[var(--primary)] text-white"
: isExcluded
? "bg-[var(--negative)] text-white line-through"
: hasActive
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
title={t("reports.pivot.rightClickExclude")}
>
{val}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Values */}
<ZoneSection
label={t("reports.pivot.values")}
items={config.values}
getLabel={measureLabel}
onRemove={(id) => removeFrom("values", id)}
accent
/>
{/* Context menu */}
{menuTarget && (
<div
ref={menuRef}
className="fixed z-50 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1 min-w-[140px]"
style={{ left: menuTarget.x, top: menuTarget.y }}
>
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
{menuTarget.type === "measure" ? (
<button
onClick={() => assignTo("values")}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{zoneLabels.values}
</button>
) : (
getAvailableZones(menuTarget.id).map((zone) => (
<button
key={zone}
onClick={() => assignTo(zone)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{zoneLabels[zone]}
</button>
))
)}
</div>
)}
</div>
);
}
function ZoneSection({
label,
items,
getLabel,
onRemove,
accent,
}: {
label: string;
items: string[];
getLabel: (id: string) => string;
onRemove: (id: string) => void;
accent?: boolean;
}) {
return (
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{label}</h3>
{items.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((id) => (
<span
key={id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs ${
accent
? "bg-[var(--primary)]/10 text-[var(--primary)]"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
>
{getLabel(id)}
<button onClick={() => onRemove(id)} className="hover:text-[var(--negative)]">
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,295 @@
import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
const STORAGE_KEY = "pivot-subtotals-position";
interface DynamicReportTableProps {
config: PivotConfig;
result: PivotResult;
}
/** A pivoted row: one per unique combination of row dimensions */
interface PivotedRow {
rowKeys: Record<string, string>; // row-dimension values
cells: Record<string, Record<string, number>>; // colValue → measure → value
}
/** Pivot raw result rows into one PivotedRow per unique row-key combination */
function pivotRows(
rows: PivotResultRow[],
rowDims: string[],
colDims: string[],
measures: string[],
): PivotedRow[] {
const map = new Map<string, PivotedRow>();
for (const row of rows) {
const rowKey = rowDims.map((d) => row.keys[d] || "").join("\0");
let pivoted = map.get(rowKey);
if (!pivoted) {
const rowKeys: Record<string, string> = {};
for (const d of rowDims) rowKeys[d] = row.keys[d] || "";
pivoted = { rowKeys, cells: {} };
map.set(rowKey, pivoted);
}
const colKey = colDims.length > 0
? colDims.map((d) => row.keys[d] || "").join("\0")
: "__all__";
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
for (const m of measures) {
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
}
}
return Array.from(map.values());
}
interface GroupNode {
key: string;
label: string;
pivotedRows: PivotedRow[];
children: GroupNode[];
}
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
if (depth >= rowDims.length) return [];
const dim = rowDims[depth];
const map = new Map<string, PivotedRow[]>();
for (const row of rows) {
const key = row.rowKeys[dim] || "";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(row);
}
const groups: GroupNode[] = [];
for (const [key, groupRows] of map) {
groups.push({
key,
label: key,
pivotedRows: groupRows,
children: buildGroups(groupRows, rowDims, depth + 1),
});
}
return groups;
}
function computeSubtotals(
rows: PivotedRow[],
measures: string[],
colValues: string[],
): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {};
for (const colVal of colValues) {
result[colVal] = {};
for (const m of measures) {
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 0);
}
}
return result;
}
export default function DynamicReportTable({ config, result }: DynamicReportTableProps) {
const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "top";
});
const toggleSubtotals = () => {
setSubtotalsOnTop((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
return next;
});
};
const rowDims = config.rows;
const colDims = config.columns;
const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
const measures = config.values;
// Display label for a composite column key (joined with \0)
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
const pivotedRows = useMemo(
() => pivotRows(result.rows, rowDims, colDims, measures),
[result.rows, rowDims, colDims, measures],
);
if (pivotedRows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noData")}
</div>
);
}
const groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{rowDims.length > 1 && (
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
{rowDims.map((dim) => (
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{fieldLabel(dim)}
</th>
))}
{colValues.map((colVal) =>
measures.map((m) => (
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)}${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
</th>
))
)}
</tr>
</thead>
<tbody>
{rowDims.length === 0 ? (
<tr className="border-b border-[var(--border)]/50">
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
) : (
groups.map((group) => (
<GroupRows
key={group.key}
group={group}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={0}
subtotalsOnTop={subtotalsOnTop}
/>
))
)}
{/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-3">
{t("reports.pivot.total")}
</td>
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
</tbody>
</table>
</div>
</div>
);
}
function GroupRows({
group,
colValues,
measures,
rowDims,
depth,
subtotalsOnTop,
}: {
group: GroupNode;
colValues: string[];
measures: string[];
rowDims: string[];
depth: number;
subtotalsOnTop: boolean;
}) {
const isLeafLevel = depth === rowDims.length - 1;
const subtotals = computeSubtotals(group.pivotedRows, measures, colValues);
const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
<td className="px-3 py-1.5" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
{group.label}
</td>
{depth < rowDims.length - 1 && <td colSpan={rowDims.length - depth - 1} />}
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`sub-${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(subtotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
) : null;
if (isLeafLevel) {
// Render one table row per pivoted row (already deduplicated by row keys)
return (
<>
{group.pivotedRows.map((pRow, i) => (
<tr key={i} className="border-b border-[var(--border)]/50">
{rowDims.map((dim, di) => (
<td
key={dim}
className="px-3 py-1.5"
style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
>
{pRow.rowKeys[dim] || ""}
</td>
))}
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
))}
</>
);
}
const childContent = group.children.map((child) => (
<GroupRows
key={child.key}
group={child}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={depth + 1}
subtotalsOnTop={subtotalsOnTop}
/>
));
return (
<Fragment>
{subtotalsOnTop && subtotalRow}
{childContent}
{!subtotalsOnTop && subtotalRow}
</Fragment>
);
}

View file

@ -1,76 +0,0 @@
import { useTranslation } from "react-i18next";
import { BarChart, Bar, XAxis, YAxis, Cell, ReferenceLine, Tooltip, ResponsiveContainer } from "recharts";
import type { HighlightMover } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
export interface HighlightsTopMoversChartProps {
movers: HighlightMover[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
export default function HighlightsTopMoversChart({ movers }: HighlightsTopMoversChartProps) {
const { t, i18n } = useTranslation();
if (movers.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const chartData = movers
.map((m, i) => ({
name: m.categoryName,
color: m.categoryColor,
delta: m.deltaAbs,
index: i,
}))
.sort((a, b) => a.delta - b.delta);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={Math.max(200, chartData.length * 36 + 40)}>
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
<ChartPatternDefs
prefix="highlights-movers"
categories={chartData.map((d) => ({ color: d.color, index: d.index }))}
/>
<XAxis
type="number"
tickFormatter={(v) => formatCurrency(v, i18n.language)}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis type="category" dataKey="name" width={120} stroke="var(--muted-foreground)" fontSize={11} />
<ReferenceLine x={0} stroke="var(--border)" />
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Bar dataKey="delta">
{chartData.map((entry) => (
<Cell
key={entry.name}
fill={getPatternFill("highlights-movers", entry.index, entry.color)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,148 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { HighlightMover } from "../../shared/types";
type SortKey = "categoryName" | "previous" | "current" | "deltaAbs" | "deltaPct";
export interface HighlightsTopMoversTableProps {
movers: HighlightMover[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
export default function HighlightsTopMoversTable({ movers }: HighlightsTopMoversTableProps) {
const { t, i18n } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>("deltaAbs");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const sorted = [...movers].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "categoryName":
cmp = a.categoryName.localeCompare(b.categoryName);
break;
case "previous":
cmp = a.previousAmount - b.previousAmount;
break;
case "current":
cmp = a.currentAmount - b.currentAmount;
break;
case "deltaAbs":
cmp = Math.abs(a.deltaAbs) - Math.abs(b.deltaAbs);
break;
case "deltaPct":
cmp = (a.deltaPct ?? 0) - (b.deltaPct ?? 0);
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
}
const headerCell = (key: SortKey, label: string, align: "left" | "right") => (
<th
onClick={() => toggleSort(key)}
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
>
{label}
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
</th>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
{headerCell("categoryName", t("reports.highlights.category"), "left")}
{headerCell("previous", t("reports.highlights.previousAmount"), "right")}
{headerCell("current", t("reports.highlights.currentAmount"), "right")}
{headerCell("deltaAbs", t("reports.highlights.variationAbs"), "right")}
{headerCell("deltaPct", t("reports.highlights.variationPct"), "right")}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
sorted.map((mover) => (
<tr
key={`${mover.categoryId ?? "uncat"}-${mover.categoryName}`}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2">
<span className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: mover.categoryColor }}
/>
{mover.categoryName}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(mover.previousAmount, i18n.language)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(mover.currentAmount, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color:
mover.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatSignedCurrency(mover.deltaAbs, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums"
style={{
color:
mover.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatPct(mover.deltaPct, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,85 +0,0 @@
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
export interface HighlightsTopTransactionsListProps {
transactions: RecentTransaction[];
windowDays: 30 | 60 | 90;
onWindowChange: (days: 30 | 60 | 90) => void;
onContextMenuRow?: (event: React.MouseEvent, transaction: RecentTransaction) => void;
}
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function HighlightsTopTransactionsList({
transactions,
windowDays,
onWindowChange,
onContextMenuRow,
}: HighlightsTopTransactionsListProps) {
const { t, i18n } = useTranslation();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<h3 className="text-sm font-semibold">{t("reports.highlights.topTransactions")}</h3>
<div className="flex gap-1">
{([30, 60, 90] as const).map((days) => (
<button
key={days}
type="button"
onClick={() => onWindowChange(days)}
aria-pressed={windowDays === days}
className={`text-xs px-2 py-1 rounded ${
windowDays === days
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
{t(`reports.highlights.windowDays${days}`)}
</button>
))}
</div>
</div>
{transactions.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</p>
) : (
<ul className="divide-y divide-[var(--border)]">
{transactions.map((tx) => (
<li
key={tx.id}
onContextMenu={onContextMenuRow ? (e) => onContextMenuRow(e, tx) : undefined}
className="flex items-center gap-3 px-4 py-2 text-sm"
>
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
{tx.category_name && (
<span className="text-xs text-[var(--muted-foreground)] flex-shrink-0">
{tx.category_name}
</span>
)}
<span
className="tabular-nums font-medium flex-shrink-0"
style={{ color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)" }}
>
{formatAmount(tx.amount, i18n.language)}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -1,55 +0,0 @@
import { useTranslation } from "react-i18next";
import type { HighlightsData } from "../../shared/types";
import HubNetBalanceTile from "./HubNetBalanceTile";
import HubTopMoversTile from "./HubTopMoversTile";
import HubTopTransactionsTile from "./HubTopTransactionsTile";
export interface HubHighlightsPanelProps {
data: HighlightsData | null;
isLoading: boolean;
error: string | null;
}
export default function HubHighlightsPanel({ data, isLoading, error }: HubHighlightsPanelProps) {
const { t } = useTranslation();
if (error) {
return (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
);
}
if (!data) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 mb-6 text-center text-[var(--muted-foreground)]">
{isLoading ? t("common.loading") : t("reports.empty.noData")}
</div>
);
}
const series = data.monthlyBalanceSeries.map((m) => m.netBalance);
return (
<section className={`mb-6 ${isLoading ? "opacity-60" : ""}`} aria-busy={isLoading}>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.hub.highlights")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<HubNetBalanceTile
label={t("reports.highlights.netBalanceCurrent")}
amount={data.netBalanceCurrent}
series={series}
/>
<HubNetBalanceTile
label={t("reports.highlights.netBalanceYtd")}
amount={data.netBalanceYtd}
series={series}
/>
<HubTopMoversTile movers={data.topMovers} />
<HubTopTransactionsTile transactions={data.topTransactions} />
</div>
</section>
);
}

View file

@ -1,35 +0,0 @@
import { useTranslation } from "react-i18next";
import Sparkline from "./Sparkline";
export interface HubNetBalanceTileProps {
label: string;
amount: number;
series: number[];
}
function formatSigned(amount: number, language: string): string {
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
}).format(amount);
return formatted;
}
export default function HubNetBalanceTile({ label, amount, series }: HubNetBalanceTileProps) {
const { i18n } = useTranslation();
const positive = amount >= 0;
const color = positive ? "var(--positive, #10b981)" : "var(--negative, #ef4444)";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{label}
</span>
<span className="text-2xl font-bold" style={{ color }}>
{formatSigned(amount, i18n.language)}
</span>
<Sparkline data={series} color={color} height={28} />
</div>
);
}

View file

@ -1,24 +0,0 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
export interface HubReportNavCardProps {
to: string;
icon: ReactNode;
title: string;
description: string;
}
export default function HubReportNavCard({ to, icon, title, description }: HubReportNavCardProps) {
return (
<Link
to={to}
className="group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
>
<div className="text-[var(--primary)]">{icon}</div>
<h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]">
{title}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
</Link>
);
}

View file

@ -1,105 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpRight, ArrowDownRight } from "lucide-react";
import type { HighlightMover } from "../../shared/types";
export interface HubTopMoversTileProps {
movers: HighlightMover[];
limit?: number;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(pct / 100);
}
export default function HubTopMoversTile({ movers, limit = 3 }: HubTopMoversTileProps) {
const { t, i18n } = useTranslation();
const [mode, setMode] = useState<"abs" | "pct">("abs");
const visible = movers.slice(0, limit);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.topMovers")}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setMode("abs")}
aria-pressed={mode === "abs"}
className={`text-xs px-2 py-0.5 rounded ${
mode === "abs"
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
$
</button>
<button
type="button"
onClick={() => setMode("pct")}
aria-pressed={mode === "pct"}
className={`text-xs px-2 py-0.5 rounded ${
mode === "pct"
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
%
</button>
</div>
</div>
{visible.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("reports.empty.noData")}</p>
) : (
<ul className="flex flex-col gap-1">
{visible.map((mover) => {
const isUp = mover.deltaAbs >= 0;
const Icon = isUp ? ArrowUpRight : ArrowDownRight;
const color = isUp ? "var(--negative, #ef4444)" : "var(--positive, #10b981)";
return (
<li
key={`${mover.categoryId ?? "uncat"}-${mover.categoryName}`}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: mover.categoryColor }}
/>
<span className="truncate">{mover.categoryName}</span>
</span>
<span className="flex items-center gap-1 flex-shrink-0" style={{ color }}>
<Icon size={14} />
<span className="tabular-nums font-medium">
{mode === "abs"
? formatCurrency(mover.deltaAbs, i18n.language)
: formatPct(mover.deltaPct, i18n.language)}
</span>
</span>
</li>
);
})}
</ul>
)}
<p className="text-[10px] text-[var(--muted-foreground)]">
{t("reports.highlights.vsLastMonth")}
</p>
</div>
);
}

View file

@ -1,54 +0,0 @@
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
export interface HubTopTransactionsTileProps {
transactions: RecentTransaction[];
limit?: number;
}
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function HubTopTransactionsTile({
transactions,
limit = 5,
}: HubTopTransactionsTileProps) {
const { t, i18n } = useTranslation();
const visible = transactions.slice(0, limit);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.topTransactions")}
</span>
{visible.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("reports.empty.noData")}</p>
) : (
<ul className="flex flex-col gap-1.5">
{visible.map((tx) => (
<li key={tx.id} className="flex items-center gap-2 text-sm">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="truncate flex-1 min-w-0">{tx.description}</span>
<span
className="tabular-nums font-medium flex-shrink-0"
style={{ color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)" }}
>
{formatAmount(tx.amount, i18n.language)}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -37,7 +37,7 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> <tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.month")} {t("reports.pivot.month")}
</th> </th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.income")} {t("dashboard.income")}

View file

@ -0,0 +1,166 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, Search } from "lucide-react";
import type { ImportSource } from "../../shared/types";
import type { CategoryTypeFilter } from "../../hooks/useReports";
interface ReportFilterPanelProps {
categories: { name: string; color: string }[];
hiddenCategories: Set<string>;
onToggleHidden: (name: string) => void;
onShowAll: () => void;
sources: ImportSource[];
selectedSourceId: number | null;
onSourceChange: (id: number | null) => void;
categoryType?: CategoryTypeFilter;
onCategoryTypeChange?: (type: CategoryTypeFilter) => void;
}
export default function ReportFilterPanel({
categories,
hiddenCategories,
onToggleHidden,
onShowAll,
sources,
selectedSourceId,
onSourceChange,
categoryType,
onCategoryTypeChange,
}: ReportFilterPanelProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState(false);
const filtered = search
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: categories;
const allVisible = hiddenCategories.size === 0;
const allHidden = hiddenCategories.size === categories.length;
return (
<div className="w-56 shrink-0 sticky top-4 self-start space-y-3">
{/* Source filter */}
{sources.length > 1 && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("transactions.table.source")}
</div>
<div className="border-t border-[var(--border)] px-2 py-2">
<select
value={selectedSourceId ?? ""}
onChange={(e) => onSourceChange(e.target.value ? Number(e.target.value) : null)}
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("transactions.filters.allSources")}</option>
{sources.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
</div>
)}
{/* Type filter */}
{onCategoryTypeChange && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("categories.type")}
</div>
<div className="border-t border-[var(--border)] px-2 py-2">
<select
value={categoryType ?? ""}
onChange={(e) => {
const v = e.target.value;
const valid: CategoryTypeFilter[] = ["expense", "income", "transfer"];
onCategoryTypeChange(valid.includes(v as CategoryTypeFilter) ? (v as CategoryTypeFilter) : null);
}}
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("reports.filters.allTypes")}</option>
<option value="expense">{t("categories.expense")}</option>
<option value="income">{t("categories.income")}</option>
<option value="transfer">{t("categories.transfer")}</option>
</select>
</div>
</div>
)}
{/* Category filter */}
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm font-medium text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("reports.filters.title")}
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
{categories.length - hiddenCategories.size}/{categories.length}
</span>
</button>
{!collapsed && (
<div className="border-t border-[var(--border)]">
<div className="px-2 py-2">
<div className="relative">
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("reports.filters.search")}
className="w-full pl-7 pr-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
</div>
<div className="px-2 pb-1 flex gap-1">
<button
onClick={onShowAll}
disabled={allVisible}
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
>
{t("reports.filters.all")}
</button>
<button
onClick={() => categories.forEach((c) => { if (!hiddenCategories.has(c.name)) onToggleHidden(c.name); })}
disabled={allHidden}
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
>
{t("reports.filters.none")}
</button>
</div>
<div className="max-h-64 overflow-y-auto px-2 pb-2 space-y-0.5">
{filtered.map((cat) => {
const visible = !hiddenCategories.has(cat.name);
return (
<label
key={cat.name}
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-[var(--muted)] cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={visible}
onChange={() => onToggleHidden(cat.name)}
className="rounded border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)] h-3.5 w-3.5"
/>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
<span className={`text-xs truncate ${visible ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)] line-through"}`}>
{cat.name}
</span>
</label>
);
})}
</div>
</div>
)}
</div>}
</div>
);
}

View file

@ -1,39 +0,0 @@
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
export interface SparklineProps {
data: number[];
color?: string;
width?: number | `${number}%`;
height?: number;
strokeWidth?: number;
}
export default function Sparkline({
data,
color = "var(--primary)",
width = "100%",
height = 32,
strokeWidth = 1.5,
}: SparklineProps) {
if (data.length === 0) {
return <div style={{ width, height }} />;
}
const chartData = data.map((value, index) => ({ index, value }));
return (
<ResponsiveContainer width={width} height={height}>
<LineChart data={chartData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={strokeWidth}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -1,46 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readViewMode } from "./ViewModeToggle";
describe("readViewMode", () => {
const store = new Map<string, string>();
const mockLocalStorage = {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => store.clear()),
key: vi.fn(),
length: 0,
};
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", mockLocalStorage);
});
it("returns fallback when key is missing", () => {
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
});
it("returns 'chart' when stored value is 'chart'", () => {
store.set("reports-viewmode-highlights", "chart");
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
});
it("returns 'table' when stored value is 'table'", () => {
store.set("reports-viewmode-highlights", "table");
expect(readViewMode("reports-viewmode-highlights")).toBe("table");
});
it("ignores invalid stored values and returns fallback", () => {
store.set("reports-viewmode-highlights", "bogus");
expect(readViewMode("reports-viewmode-highlights", "table")).toBe("table");
});
it("respects custom fallback when provided", () => {
expect(readViewMode("reports-viewmode-compare", "table")).toBe("table");
});
});

View file

@ -1,52 +0,0 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Table } from "lucide-react";
export type ViewMode = "chart" | "table";
export interface ViewModeToggleProps {
value: ViewMode;
onChange: (mode: ViewMode) => void;
/** localStorage key used to persist the preference per section. */
storageKey?: string;
}
export function readViewMode(storageKey: string, fallback: ViewMode = "chart"): ViewMode {
if (typeof localStorage === "undefined") return fallback;
const saved = localStorage.getItem(storageKey);
return saved === "chart" || saved === "table" ? saved : fallback;
}
export default function ViewModeToggle({ value, onChange, storageKey }: ViewModeToggleProps) {
const { t } = useTranslation();
useEffect(() => {
if (storageKey) localStorage.setItem(storageKey, value);
}, [value, storageKey]);
const options: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.viewMode.chart") },
{ mode: "table", icon: <Table size={14} />, label: t("reports.viewMode.table") },
];
return (
<div className="inline-flex gap-1" role="group" aria-label={t("reports.viewMode.chart")}>
{options.map(({ mode, icon, label }) => (
<button
key={mode}
type="button"
onClick={() => onChange(mode)}
aria-pressed={value === mode}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === mode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
</div>
);
}

View file

@ -1,111 +0,0 @@
import { useTranslation } from "react-i18next";
import { Target } from "lucide-react";
import type { CartesBudgetAdherence } from "../../../shared/types";
export interface BudgetAdherenceCardProps {
adherence: CartesBudgetAdherence;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(pct / 100);
}
export default function BudgetAdherenceCard({ adherence }: BudgetAdherenceCardProps) {
const { t, i18n } = useTranslation();
const { categoriesInTarget, categoriesTotal, worstOverruns } = adherence;
const score = categoriesTotal === 0 ? null : (categoriesInTarget / categoriesTotal) * 100;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Target size={16} className="text-[var(--primary)]" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
{t("reports.cartes.budgetAdherenceTitle")}
</h3>
</div>
{categoriesTotal === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.cartes.budgetAdherenceEmpty")}
</div>
) : (
<>
<div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{categoriesInTarget}
<span className="text-sm text-[var(--muted-foreground)] font-normal">
{" / "}
{categoriesTotal}
</span>
</div>
<div className="text-xs text-[var(--muted-foreground)]">
{t("reports.cartes.budgetAdherenceSubtitle", {
score:
score !== null
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
}).format(score / 100)
: "—",
})}
</div>
</div>
{worstOverruns.length > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-[var(--border)]">
<div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{t("reports.cartes.budgetAdherenceWorst")}
</div>
{worstOverruns.map((r) => {
const progressPct = r.budget > 0 ? Math.min((r.actual / r.budget) * 100, 200) : 0;
return (
<div key={r.categoryId} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: r.categoryColor }}
/>
<span className="truncate text-[var(--foreground)]">{r.categoryName}</span>
</span>
<span className="tabular-nums text-[var(--muted-foreground)]">
{formatCurrency(r.actual, i18n.language)}
{" / "}
{formatCurrency(r.budget, i18n.language)}
<span className="text-[var(--negative)] ml-1 font-medium">
{formatPct(r.overrunPct, i18n.language)}
</span>
</span>
</div>
<div
className="h-1.5 rounded-full bg-[var(--muted)] overflow-hidden"
aria-hidden
>
<div
className="h-full bg-[var(--negative)]"
style={{ width: `${Math.min(progressPct, 100)}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</>
)}
</div>
);
}

View file

@ -1,97 +0,0 @@
import { useTranslation } from "react-i18next";
import {
ComposedChart,
Bar,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
} from "recharts";
import type { CartesMonthFlow } from "../../../shared/types";
export interface IncomeExpenseOverlayChartProps {
flow: CartesMonthFlow[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatMonthShort(month: string, language: string): string {
const [y, m] = month.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return month;
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "short",
year: "2-digit",
}).format(new Date(y, m - 1, 1));
}
export default function IncomeExpenseOverlayChart({ flow }: IncomeExpenseOverlayChartProps) {
const { t, i18n } = useTranslation();
if (flow.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const data = flow.map((p) => ({
...p,
label: formatMonthShort(p.month, i18n.language),
}));
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<div className="text-sm font-medium text-[var(--foreground)] mb-3">
{t("reports.cartes.flowChartTitle")}
</div>
<ResponsiveContainer width="100%" height={260}>
<ComposedChart data={data} margin={{ top: 10, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="label" stroke="var(--muted-foreground)" fontSize={11} />
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v) => formatCurrency(v, i18n.language)}
width={80}
/>
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<ReferenceLine y={0} stroke="var(--border)" />
<Bar dataKey="income" name={t("reports.cartes.income")} fill="var(--positive)" />
<Bar dataKey="expenses" name={t("reports.cartes.expenses")} fill="var(--negative)" />
<Line
type="monotone"
dataKey="net"
name={t("reports.cartes.net")}
stroke="var(--primary)"
strokeWidth={2}
dot={{ r: 2 }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,139 +0,0 @@
import { useTranslation } from "react-i18next";
import KpiSparkline from "./KpiSparkline";
import type { CartesKpi, CartesKpiId } from "../../../shared/types";
export interface KpiCardProps {
id: CartesKpiId;
title: string;
kpi: CartesKpi;
format: "currency" | "percent";
/** When true, positive deltas are rendered in red (e.g. rising expenses). */
deltaIsBadWhenUp?: boolean;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPercent(value: number, language: string, signed = false): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: signed ? "always" : "auto",
}).format(value / 100);
}
function formatValue(value: number, format: "currency" | "percent", language: string): string {
return format === "currency" ? formatCurrency(value, language) : formatPercent(value, language);
}
function formatDeltaAbs(
value: number,
format: "currency" | "percent",
language: string,
): string {
if (format === "currency") {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(value);
}
// Savings rate delta in percentage points — not a % of %
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
maximumFractionDigits: 1,
signDisplay: "always",
}).format(value);
return `${formatted} pt`;
}
interface DeltaBadgeProps {
abs: number | null;
pct: number | null;
label: string;
format: "currency" | "percent";
language: string;
deltaIsBadWhenUp: boolean;
}
function DeltaBadge({ abs, pct, label, format, language, deltaIsBadWhenUp }: DeltaBadgeProps) {
if (abs === null) {
return (
<div className="flex flex-col items-start">
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</span>
<span className="text-xs text-[var(--muted-foreground)] italic"></span>
</div>
);
}
const isUp = abs >= 0;
const isBad = deltaIsBadWhenUp ? isUp : !isUp;
// Treat near-zero as neutral
const isNeutral = abs === 0;
const colorClass = isNeutral
? "text-[var(--muted-foreground)]"
: isBad
? "text-[var(--negative)]"
: "text-[var(--positive)]";
const absText = formatDeltaAbs(abs, format, language);
const pctText = pct === null ? "" : ` (${formatPercent(pct, language, true)})`;
return (
<div className="flex flex-col items-start">
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</span>
<span className={`text-xs font-medium tabular-nums ${colorClass}`}>
{absText}
{pctText}
</span>
</div>
);
}
export default function KpiCard({
id,
title,
kpi,
format,
deltaIsBadWhenUp = false,
}: KpiCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.language;
return (
<div
data-kpi={id}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3"
>
<div className="text-sm text-[var(--muted-foreground)]">{title}</div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{formatValue(kpi.current, format, language)}
</div>
<KpiSparkline data={kpi.sparkline} />
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">
<DeltaBadge
abs={kpi.deltaMoMAbs}
pct={kpi.deltaMoMPct}
label={t("reports.cartes.deltaMoMLabel")}
format={format}
language={language}
deltaIsBadWhenUp={deltaIsBadWhenUp}
/>
<DeltaBadge
abs={kpi.deltaYoYAbs}
pct={kpi.deltaYoYPct}
label={t("reports.cartes.deltaYoYLabel")}
format={format}
language={language}
deltaIsBadWhenUp={deltaIsBadWhenUp}
/>
</div>
</div>
);
}

View file

@ -1,50 +0,0 @@
import { LineChart, Line, ResponsiveContainer, YAxis, ReferenceDot } from "recharts";
import type { CartesSparklinePoint } from "../../../shared/types";
export interface KpiSparklineProps {
data: CartesSparklinePoint[];
color?: string;
height?: number;
}
/**
* Compact line chart with the reference month (the last point) highlighted
* by a filled dot. Rendered inside the KPI cards on the Cartes page.
*/
export default function KpiSparkline({
data,
color = "var(--primary)",
height = 40,
}: KpiSparklineProps) {
if (data.length === 0) {
return <div style={{ width: "100%", height }} />;
}
const chartData = data.map((p, index) => ({ index, value: p.value, month: p.month }));
const lastIndex = chartData.length - 1;
const lastValue = chartData[lastIndex]?.value ?? 0;
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={chartData} margin={{ top: 4, right: 6, bottom: 2, left: 2 }}>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.75}
dot={false}
isAnimationActive={false}
/>
<ReferenceDot
x={lastIndex}
y={lastValue}
r={3.5}
fill={color}
stroke="var(--card)"
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -1,106 +0,0 @@
import { useTranslation } from "react-i18next";
import { CalendarClock } from "lucide-react";
import type { CartesSeasonality } from "../../../shared/types";
export interface SeasonalityCardProps {
seasonality: CartesSeasonality;
referenceYear: number;
referenceMonth: number;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number, language: string, signed = true): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: signed ? "always" : "auto",
}).format(pct / 100);
}
function formatMonthYear(year: number, month: number, language: string): string {
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(new Date(year, month - 1, 1));
}
export default function SeasonalityCard({
seasonality,
referenceYear,
referenceMonth,
}: SeasonalityCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.language;
const { referenceAmount, historicalYears, historicalAverage, deviationPct } = seasonality;
const refLabel = formatMonthYear(referenceYear, referenceMonth, language);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<CalendarClock size={16} className="text-[var(--primary)]" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
{t("reports.cartes.seasonalityTitle")}
</h3>
</div>
{historicalYears.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.cartes.seasonalityEmpty")}
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-[var(--muted-foreground)]">{refLabel}</span>
<span className="text-lg font-bold tabular-nums text-[var(--foreground)]">
{formatCurrency(referenceAmount, language)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs">
{historicalYears.map((y) => (
<div
key={y.year}
className="flex items-center justify-between text-[var(--muted-foreground)]"
>
<span>{y.year}</span>
<span className="tabular-nums">{formatCurrency(y.amount, language)}</span>
</div>
))}
{historicalAverage !== null && (
<div className="flex items-center justify-between border-t border-[var(--border)] pt-1 mt-1 text-[var(--foreground)]">
<span>{t("reports.cartes.seasonalityAverage")}</span>
<span className="tabular-nums font-medium">
{formatCurrency(historicalAverage, language)}
</span>
</div>
)}
</div>
{deviationPct !== null && (
<div
className={`text-xs font-medium ${
deviationPct > 5
? "text-[var(--negative)]"
: deviationPct < -5
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
>
{t("reports.cartes.seasonalityDeviation", {
pct: formatPct(deviationPct, language),
})}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -1,86 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TrendingUp, TrendingDown } from "lucide-react";
import type { CartesTopMover } from "../../../shared/types";
export interface TopMoversListProps {
movers: CartesTopMover[];
direction: "up" | "down";
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
function categoryHref(categoryId: number | null): string {
if (categoryId === null) return "/transactions";
const params = new URLSearchParams(window.location.search);
params.set("cat", String(categoryId));
return `/reports/category?${params.toString()}`;
}
export default function TopMoversList({ movers, direction }: TopMoversListProps) {
const { t, i18n } = useTranslation();
const title =
direction === "up"
? t("reports.cartes.topMoversUp")
: t("reports.cartes.topMoversDown");
const Icon = direction === "up" ? TrendingUp : TrendingDown;
const accentClass = direction === "up" ? "text-[var(--negative)]" : "text-[var(--positive)]";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Icon size={16} className={accentClass} />
<h3 className="text-sm font-medium text-[var(--foreground)]">{title}</h3>
</div>
{movers.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.empty.noData")}
</div>
) : (
<ul className="flex flex-col gap-1">
{movers.map((m) => (
<li key={`${m.categoryId ?? "uncat"}-${m.categoryName}`}>
<Link
to={categoryHref(m.categoryId)}
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] transition-colors"
>
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: m.categoryColor }}
/>
<span className="truncate text-sm text-[var(--foreground)]">
{m.categoryName}
</span>
</span>
<span className={`text-xs font-medium tabular-nums ${accentClass}`}>
{formatSignedCurrency(m.deltaAbs, i18n.language)}
<span className="text-[var(--muted-foreground)] ml-1">
{formatPct(m.deltaPct, i18n.language)}
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -1,79 +0,0 @@
import { useTranslation } from "react-i18next";
import { User, LogIn, LogOut, Loader2, AlertCircle } from "lucide-react";
import { useAuth } from "../../hooks/useAuth";
export default function AccountCard() {
const { t } = useTranslation();
const { state, login, logout } = useAuth();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<User size={18} />
{t("account.title")}
<span className="text-xs font-normal text-[var(--muted-foreground)]">
{t("account.optional")}
</span>
</h2>
{state.status === "error" && state.error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p>
</div>
)}
{state.status === "authenticated" && state.account && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[var(--primary)] text-white flex items-center justify-center font-semibold text-sm">
{(state.account.name || state.account.email).charAt(0).toUpperCase()}
</div>
<div>
<p className="font-medium">
{state.account.name || state.account.email}
</p>
{state.account.name && (
<p className="text-sm text-[var(--muted-foreground)]">
{state.account.email}
</p>
)}
</div>
</div>
<button
type="button"
onClick={logout}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
<LogOut size={14} />
{t("account.signOut")}
</button>
</div>
)}
{(state.status === "unauthenticated" || state.status === "idle") && (
<div className="space-y-3">
<p className="text-sm text-[var(--muted-foreground)]">
{t("account.description")}
</p>
<button
type="button"
onClick={login}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
>
<LogIn size={14} />
{t("account.signIn")}
</button>
</div>
)}
{state.status === "loading" && (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 size={14} className="animate-spin" />
{t("common.loading")}
</div>
)}
</div>
);
}

View file

@ -1,67 +0,0 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Globe } from "lucide-react";
interface FeedbackConsentDialogProps {
onAccept: () => void;
onCancel: () => void;
}
export default function FeedbackConsentDialog({
onAccept,
onCancel,
}: FeedbackConsentDialogProps) {
const { t } = useTranslation();
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onCancel]);
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Globe size={18} />
{t("feedback.consent.title")}
</h2>
<button
onClick={onCancel}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
aria-label={t("feedback.dialog.cancel")}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4 space-y-3 text-sm text-[var(--muted-foreground)]">
<p>{t("feedback.consent.body")}</p>
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
<button
onClick={onCancel}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
{t("feedback.dialog.cancel")}
</button>
<button
onClick={onAccept}
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
{t("feedback.consent.accept")}
</button>
</div>
</div>
</div>,
document.body,
);
}

View file

@ -1,239 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { X, MessageSquarePlus, CheckCircle, AlertCircle } from "lucide-react";
import { useFeedback } from "../../hooks/useFeedback";
import { useAuth } from "../../hooks/useAuth";
import { getRecentErrorLogs } from "../../services/logService";
import {
getFeedbackUserAgent,
type FeedbackContext,
} from "../../services/feedbackService";
const MAX_CONTENT_LENGTH = 2000;
const LOGS_SUFFIX_MAX = 800;
const RECENT_ERROR_LOGS_N = 20;
const AUTO_CLOSE_DELAY_MS = 2000;
interface FeedbackDialogProps {
onClose: () => void;
}
export default function FeedbackDialog({ onClose }: FeedbackDialogProps) {
const { t, i18n } = useTranslation();
const location = useLocation();
const { state: authState } = useAuth();
const { state: feedbackState, submit, reset } = useFeedback();
const [content, setContent] = useState("");
const [includeContext, setIncludeContext] = useState(false);
const [includeLogs, setIncludeLogs] = useState(false);
const [identify, setIdentify] = useState(false);
const isAuthenticated = authState.status === "authenticated";
const userEmail = authState.account?.email ?? null;
const trimmed = content.trim();
const isSending = feedbackState.status === "sending";
const isSuccess = feedbackState.status === "success";
const canSubmit = trimmed.length > 0 && !isSending && !isSuccess;
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape" && !isSending) onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose, isSending]);
// Auto-close after success
useEffect(() => {
if (!isSuccess) return;
const timer = setTimeout(() => {
onClose();
reset();
}, AUTO_CLOSE_DELAY_MS);
return () => clearTimeout(timer);
}, [isSuccess, onClose, reset]);
const errorMessage = useMemo(() => {
if (feedbackState.status !== "error" || !feedbackState.errorCode) return null;
switch (feedbackState.errorCode) {
case "rate_limit":
return t("feedback.toast.error.429");
case "invalid":
return t("feedback.toast.error.400");
case "network_error":
case "server_error":
default:
return t("feedback.toast.error.generic");
}
}, [feedbackState, t]);
async function handleSubmit() {
if (!canSubmit) return;
// Compose content with optional logs suffix, keeping total ≤ 2000 chars
let body = trimmed;
if (includeLogs) {
const rawLogs = getRecentErrorLogs(RECENT_ERROR_LOGS_N);
if (rawLogs.length > 0) {
const trimmedLogs = rawLogs.slice(-LOGS_SUFFIX_MAX);
const suffix = `\n\n---\n${t("feedback.logsHeading")}\n${trimmedLogs}`;
const available = MAX_CONTENT_LENGTH - body.length;
if (available > suffix.length) {
body = body + suffix;
} else if (available > 50) {
body = body + suffix.slice(0, available);
}
}
}
// Compose context if opted in
let context: FeedbackContext | undefined;
if (includeContext) {
let userAgent = "Simpl'Résultat";
try {
userAgent = await getFeedbackUserAgent();
} catch {
// fall back to a basic string if the Rust helper fails
}
context = {
page: location.pathname,
locale: i18n.language,
theme: document.documentElement.classList.contains("dark") ? "dark" : "light",
viewport: `${window.innerWidth}x${window.innerHeight}`,
userAgent,
timestamp: new Date().toISOString(),
};
}
const userId = identify && isAuthenticated ? userEmail : null;
await submit({ content: body, userId, context });
}
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget && !isSending) onClose();
}}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold flex items-center gap-2">
<MessageSquarePlus size={18} />
{t("feedback.dialog.title")}
</h2>
<button
onClick={onClose}
disabled={isSending}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-50"
aria-label={t("feedback.dialog.cancel")}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4 space-y-3">
{isSuccess ? (
<div className="flex items-center gap-2 text-[var(--positive)] py-8 justify-center">
<CheckCircle size={20} />
<span>{t("feedback.toast.success")}</span>
</div>
) : (
<>
<textarea
value={content}
onChange={(e) =>
setContent(e.target.value.slice(0, MAX_CONTENT_LENGTH))
}
placeholder={t("feedback.dialog.placeholder")}
rows={6}
disabled={isSending}
className="w-full px-3 py-2 rounded-lg bg-[var(--background)] border border-[var(--border)] text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--primary)] disabled:opacity-50"
/>
<div className="flex justify-end text-xs text-[var(--muted-foreground)]">
{content.length}/{MAX_CONTENT_LENGTH}
</div>
<div className="space-y-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeContext}
onChange={(e) => setIncludeContext(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>{t("feedback.checkbox.context")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeLogs}
onChange={(e) => setIncludeLogs(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>{t("feedback.checkbox.logs")}</span>
</label>
{isAuthenticated && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={identify}
onChange={(e) => setIdentify(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>
{t("feedback.checkbox.identify")}
{userEmail && (
<span className="text-[var(--muted-foreground)]">
{" "}
({userEmail})
</span>
)}
</span>
</label>
)}
</div>
{errorMessage && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
</>
)}
</div>
{!isSuccess && (
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
<button
onClick={onClose}
disabled={isSending}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{t("feedback.dialog.cancel")}
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isSending
? t("feedback.dialog.sending")
: t("feedback.dialog.submit")}
</button>
</div>
)}
</div>
</div>,
document.body,
);
}

View file

@ -1,299 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
import { useLicense } from "../../hooks/useLicense";
import {
MachineInfo,
ActivationStatus,
activateMachine,
deactivateMachine,
listActivatedMachines,
getActivationStatus,
} from "../../services/licenseService";
const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
export default function LicenseCard() {
const { t } = useTranslation();
const { state, submitKey } = useLicense();
const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false);
const [showMachines, setShowMachines] = useState(false);
const [machines, setMachines] = useState<MachineInfo[]>([]);
const [activation, setActivation] = useState<ActivationStatus | null>(null);
const [machineLoading, setMachineLoading] = useState(false);
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
const [machineError, setMachineError] = useState<string | null>(null);
const hasLicense = state.edition !== "free";
const loadActivation = useCallback(async () => {
if (!hasLicense) return;
try {
const status = await getActivationStatus();
setActivation(status);
} catch {
// Ignore — activation status is best-effort
}
}, [hasLicense]);
const loadMachines = useCallback(async () => {
setMachineLoading(true);
setMachineError(null);
try {
const list = await listActivatedMachines();
setMachines(list);
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setMachineLoading(false);
}
}, []);
useEffect(() => {
void loadActivation();
}, [loadActivation]);
const handleActivate = async () => {
setMachineLoading(true);
setMachineError(null);
try {
await activateMachine();
await loadActivation();
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setMachineLoading(false);
}
};
const handleDeactivate = async (machineId: string) => {
setDeactivatingId(machineId);
try {
await deactivateMachine(machineId);
await loadActivation();
await loadMachines();
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setDeactivatingId(null);
}
};
const toggleMachines = async () => {
const next = !showMachines;
setShowMachines(next);
if (next && machines.length === 0) {
await loadMachines();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = keyInput.trim();
if (!trimmed) return;
const result = await submitKey(trimmed);
if (result.ok) {
setKeyInput("");
setShowInput(false);
}
};
const handlePurchase = () => {
void openUrl(PURCHASE_URL);
};
const formatExpiry = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString();
};
const editionLabel = t(`license.editions.${state.edition}`);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<KeyRound size={18} />
{t("license.title")}
</h2>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("license.currentEdition")}
</p>
<p className="text-base font-medium">
{editionLabel}
{state.edition !== "free" && (
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
)}
</p>
</div>
{state.info && state.info.expires_at > 0 && (
<div className="text-right">
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.expiresAt")}
</p>
<p className="text-sm">{formatExpiry(state.info.expires_at)}</p>
</div>
)}
</div>
{state.status === "error" && state.error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p>
</div>
)}
{!showInput && (
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setShowInput(true)}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
{t("license.enterKey")}
</button>
{state.edition === "free" && (
<button
type="button"
onClick={handlePurchase}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
>
<ExternalLink size={14} />
{t("license.purchase")}
</button>
)}
</div>
)}
{showInput && (
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
placeholder={t("license.keyPlaceholder")}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
autoFocus
/>
<div className="flex gap-2">
<button
type="submit"
disabled={state.status === "validating" || !keyInput.trim()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
>
{state.status === "validating" && <Loader2 size={14} className="animate-spin" />}
{t("license.activate")}
</button>
<button
type="button"
onClick={() => {
setShowInput(false);
setKeyInput("");
}}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
{t("common.cancel")}
</button>
</div>
</form>
)}
{hasLicense && (
<div className="border-t border-[var(--border)] pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Monitor size={16} />
{t("license.machines.title")}
</h3>
<div className="flex items-center gap-2">
{activation && !activation.is_activated && (
<button
type="button"
onClick={handleActivate}
disabled={machineLoading}
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
>
{machineLoading && <Loader2 size={12} className="animate-spin" />}
{t("license.activate")}
</button>
)}
{activation?.is_activated && (
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
<CheckCircle size={12} />
{t("license.machines.activated")}
</span>
)}
<button
type="button"
onClick={toggleMachines}
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
>
{showMachines ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
{machineError && (
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<p>{machineError}</p>
</div>
)}
{showMachines && (
<div className="space-y-2">
{machineLoading && machines.length === 0 && (
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<Loader2 size={12} className="animate-spin" />
{t("common.loading")}
</div>
)}
{!machineLoading && machines.length === 0 && (
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.machines.noMachines")}
</p>
)}
{machines.map((m) => {
const isThis = activation?.machine_id === m.machine_id;
return (
<div
key={m.machine_id}
className="flex items-center justify-between px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm"
>
<div>
<span className="font-medium">
{m.machine_name || m.machine_id.slice(0, 12)}
</span>
{isThis && (
<span className="ml-2 text-xs text-[var(--positive)]">
({t("license.machines.thisMachine")})
</span>
)}
<p className="text-xs text-[var(--muted-foreground)]">
{new Date(m.activated_at).toLocaleDateString()}
</p>
</div>
<button
type="button"
onClick={() => handleDeactivate(m.machine_id)}
disabled={deactivatingId === m.machine_id}
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{deactivatingId === m.machine_id && (
<Loader2 size={10} className="animate-spin" />
)}
{t("license.machines.deactivate")}
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -1,37 +1,16 @@
import { useState, useEffect, useRef, useSyncExternalStore } from "react"; import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollText, Trash2, Copy, Check, MessageSquarePlus } from "lucide-react"; import { ScrollText, Trash2, Copy, Check } from "lucide-react";
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService"; import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
import FeedbackDialog from "./FeedbackDialog";
import FeedbackConsentDialog from "./FeedbackConsentDialog";
type Filter = "all" | LogLevel; type Filter = "all" | LogLevel;
const FEEDBACK_CONSENT_KEY = "feedbackConsentAccepted";
export default function LogViewerCard() { export default function LogViewerCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const [filter, setFilter] = useState<Filter>("all"); const [filter, setFilter] = useState<Filter>("all");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [consentOpen, setConsentOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const openFeedback = () => {
const accepted = localStorage.getItem(FEEDBACK_CONSENT_KEY) === "true";
if (accepted) {
setFeedbackOpen(true);
} else {
setConsentOpen(true);
}
};
const acceptConsent = () => {
localStorage.setItem(FEEDBACK_CONSENT_KEY, "true");
setConsentOpen(false);
setFeedbackOpen(true);
};
const logs = useSyncExternalStore(subscribe, getLogs, getLogs); const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter); const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
@ -75,13 +54,6 @@ export default function LogViewerCard() {
{t("settings.logs.title")} {t("settings.logs.title")}
</h2> </h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={openFeedback}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
<MessageSquarePlus size={14} />
{t("feedback.button")}
</button>
<button <button
onClick={handleCopy} onClick={handleCopy}
disabled={filtered.length === 0} disabled={filtered.length === 0}
@ -139,14 +111,6 @@ export default function LogViewerCard() {
)) ))
)} )}
</div> </div>
{consentOpen && (
<FeedbackConsentDialog
onAccept={acceptConsent}
onCancel={() => setConsentOpen(false)}
/>
)}
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
</div> </div>
); );
} }

View file

@ -1,69 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert, X } from "lucide-react";
import { getTokenStoreMode, TokenStoreMode } from "../../services/authService";
// Per-session dismissal flag. Kept in sessionStorage so the banner
// returns on the next app launch if the fallback condition still
// holds — this matches the acceptance criteria from issue #81.
const DISMISS_KEY = "tokenStoreFallbackBannerDismissed";
export default function TokenStoreFallbackBanner() {
const { t } = useTranslation();
const [mode, setMode] = useState<TokenStoreMode | null>(null);
const [dismissed, setDismissed] = useState<boolean>(() => {
try {
return sessionStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
let cancelled = false;
getTokenStoreMode()
.then((m) => {
if (!cancelled) setMode(m);
})
.catch(() => {
if (!cancelled) setMode(null);
});
return () => {
cancelled = true;
};
}, []);
if (mode !== "file" || dismissed) return null;
const dismiss = () => {
try {
sessionStorage.setItem(DISMISS_KEY, "1");
} catch {
// Ignore storage errors — the banner will simply hide for the
// remainder of this render cycle via state.
}
setDismissed(true);
};
return (
<div className="flex items-start gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4">
<ShieldAlert size={20} className="mt-0.5 shrink-0 text-amber-500" />
<div className="flex-1 space-y-1 text-sm">
<p className="font-semibold text-[var(--foreground)]">
{t("account.tokenStore.fallback.title")}
</p>
<p className="text-[var(--muted-foreground)]">
{t("account.tokenStore.fallback.description")}
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("common.close")}
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
</button>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { EyeOff, List } from "lucide-react"; import { EyeOff, List } from "lucide-react";
import ContextMenu from "./ContextMenu";
export interface ChartContextMenuProps { export interface ChartContextMenuProps {
x: number; x: number;
@ -20,25 +20,60 @@ export default function ChartContextMenu({
onClose, onClose,
}: ChartContextMenuProps) { }: ChartContextMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
// Adjust position to stay within viewport
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return ( return (
<ContextMenu <div
x={x} ref={menuRef}
y={y} className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
header={categoryName} style={{ left: x, top: y }}
onClose={onClose} >
items={[ <div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{ {categoryName}
icon: <List size={14} />, </div>
label: t("charts.viewTransactions"), <button
onClick: onViewDetails, onClick={() => { onViewDetails(); onClose(); }}
}, className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
{ >
icon: <EyeOff size={14} />, <List size={14} />
label: t("charts.hideCategory"), {t("charts.viewTransactions")}
onClick: onHide, </button>
}, <button
]} onClick={() => { onHide(); onClose(); }}
/> className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<EyeOff size={14} />
{t("charts.hideCategory")}
</button>
</div>
); );
} }

View file

@ -1,77 +0,0 @@
import { useEffect, useRef, type ReactNode } from "react";
export interface ContextMenuItem {
icon?: ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
header?: ReactNode;
items: ContextMenuItem[];
onClose: () => void;
}
export default function ContextMenu({ x, y, header, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
style={{ left: x, top: y }}
>
{header && (
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{header}
</div>
)}
{items.map((item, i) => (
<button
key={i}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick();
onClose();
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{item.icon}
{item.label}
</button>
))}
</div>
);
}

View file

@ -21,7 +21,6 @@ interface TransactionTableProps {
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>; onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>; onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>; onDeleteSplit: (parentId: number) => Promise<void>;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
} }
function SortIcon({ function SortIcon({
@ -51,7 +50,6 @@ export default function TransactionTable({
onLoadSplitChildren, onLoadSplitChildren,
onSaveSplit, onSaveSplit,
onDeleteSplit, onDeleteSplit,
onRowContextMenu,
}: TransactionTableProps) { }: TransactionTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
@ -137,7 +135,6 @@ export default function TransactionTable({
{rows.map((row) => ( {rows.map((row) => (
<Fragment key={row.id}> <Fragment key={row.id}>
<tr <tr
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
className="hover:bg-[var(--muted)] transition-colors" className="hover:bg-[var(--muted)] transition-colors"
> >
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td> <td className="px-3 py-2 whitespace-nowrap">{row.date}</td>

View file

@ -46,7 +46,7 @@ interface ProfileContextValue {
error: string | null; error: string | null;
switchProfile: (id: string) => Promise<void>; switchProfile: (id: string) => Promise<void>;
createProfile: (name: string, color: string, pin?: string) => Promise<void>; createProfile: (name: string, color: string, pin?: string) => Promise<void>;
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => Promise<void>; updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => Promise<void>;
deleteProfile: (id: string) => Promise<void>; deleteProfile: (id: string) => Promise<void>;
setPin: (id: string, pin: string | null) => Promise<void>; setPin: (id: string, pin: string | null) => Promise<void>;
connectActiveProfile: () => Promise<void>; connectActiveProfile: () => Promise<void>;
@ -151,7 +151,7 @@ export function ProfileProvider({ children }: { children: ReactNode }) {
} }
}, [state.config]); }, [state.config]);
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => { const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => {
if (!state.config) return; if (!state.config) return;
const newProfiles = state.config.profiles.map((p) => const newProfiles = state.config.profiles.map((p) =>

View file

@ -1,126 +0,0 @@
import { useCallback, useEffect, useReducer } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { listen } from "@tauri-apps/api/event";
import {
AccountInfo,
startOAuth,
getAccountInfo,
checkSubscriptionStatus,
logoutAccount,
} from "../services/authService";
type AuthStatus = "idle" | "loading" | "authenticated" | "unauthenticated" | "error";
interface AuthState {
status: AuthStatus;
account: AccountInfo | null;
error: string | null;
}
type AuthAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; account: AccountInfo | null }
| { type: "LOGIN_START" }
| { type: "LOGOUT" }
| { type: "ERROR"; error: string };
const initialState: AuthState = {
status: "idle",
account: null,
error: null,
};
function reducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return {
status: action.account ? "authenticated" : "unauthenticated",
account: action.account,
error: null,
};
case "LOGIN_START":
return { ...state, status: "loading", error: null };
case "LOGOUT":
return { status: "unauthenticated", account: null, error: null };
case "ERROR":
return { ...state, status: "error", error: action.error };
}
}
export function useAuth() {
const [state, dispatch] = useReducer(reducer, initialState);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
// checkSubscriptionStatus refreshes the token if last check > 24h,
// otherwise returns cached account info. Graceful on network errors.
const account = await checkSubscriptionStatus();
dispatch({ type: "LOAD_DONE", account });
} catch (e) {
// Fallback to cached account info if the check command itself fails
try {
const cached = await getAccountInfo();
dispatch({ type: "LOAD_DONE", account: cached });
} catch {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}
}, []);
const login = useCallback(async () => {
dispatch({ type: "LOGIN_START" });
try {
const url = await startOAuth();
await openUrl(url);
// The actual auth completion happens via the deep-link callback,
// which triggers handle_auth_callback on the Rust side.
// The UI should call refresh() after the callback completes.
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const logout = useCallback(async () => {
try {
await logoutAccount();
dispatch({ type: "LOGOUT" });
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Listen for deep-link auth callback events from the Rust backend
useEffect(() => {
const unlisten: Array<() => void> = [];
listen<AccountInfo>("auth-callback-success", (event) => {
dispatch({ type: "LOAD_DONE", account: event.payload });
}).then((fn) => unlisten.push(fn));
listen<string>("auth-callback-error", (event) => {
dispatch({ type: "ERROR", error: event.payload });
}).then((fn) => unlisten.push(fn));
return () => {
unlisten.forEach((fn) => fn());
};
}, []);
return { state, refresh, login, logout };
}

View file

@ -1,25 +0,0 @@
import { describe, it, expect } from "vitest";
import { defaultCartesReferencePeriod } from "./useCartes";
describe("defaultCartesReferencePeriod", () => {
it("returns the month before the given date", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 3, 15))).toEqual({
year: 2026,
month: 3,
});
});
it("wraps around January to December of the previous year", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 0, 10))).toEqual({
year: 2025,
month: 12,
});
});
it("handles the last day of a month", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 5, 30))).toEqual({
year: 2026,
month: 5,
});
});
});

View file

@ -1,103 +0,0 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CartesSnapshot } from "../shared/types";
import { getCartesSnapshot } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
interface State {
year: number;
month: number;
snapshot: CartesSnapshot | null;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SNAPSHOT"; payload: CartesSnapshot }
| { type: "SET_ERROR"; payload: string };
/**
* Default reference period for the Cartes report: the month preceding `today`.
* January wraps around to December of the previous year. Exported for tests.
*/
export function defaultCartesReferencePeriod(
today: Date = new Date(),
): { year: number; month: number } {
const y = today.getFullYear();
const m = today.getMonth() + 1;
if (m === 1) return { year: y - 1, month: 12 };
return { year: y, month: m - 1 };
}
const defaultRef = defaultCartesReferencePeriod();
const initialState: State = {
year: defaultRef.year,
month: defaultRef.month,
snapshot: null,
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_REFERENCE_PERIOD":
return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_SNAPSHOT":
return { ...state, snapshot: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useCartes() {
const { from, to, period, setPeriod, setCustomDates } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(async (year: number, month: number) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const snapshot = await getCartesSnapshot(year, month);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_SNAPSHOT", payload: snapshot });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
useEffect(() => {
fetch(state.year, state.month);
}, [fetch, state.year, state.month]);
// Keep the reference month in sync with the URL `to` date, so navigating
// via PeriodSelector works as expected.
useEffect(() => {
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
if (y !== state.year || m !== state.month) {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]);
const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []);
return {
...state,
setReferencePeriod,
from,
to,
period,
setPeriod,
setCustomDates,
};
}

View file

@ -1,85 +0,0 @@
import { useReducer, useEffect, useRef, useCallback } from "react";
import type { CategoryZoomData } from "../shared/types";
import { getCategoryZoom } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
interface State {
zoomedCategoryId: number | null;
rollupChildren: boolean;
data: CategoryZoomData | null;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_CATEGORY"; payload: number | null }
| { type: "TOGGLE_ROLLUP"; payload: boolean }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_DATA"; payload: CategoryZoomData }
| { type: "SET_ERROR"; payload: string };
const initialState: State = {
zoomedCategoryId: null,
rollupChildren: true,
data: null,
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CATEGORY":
return { ...state, zoomedCategoryId: action.payload, data: null };
case "TOGGLE_ROLLUP":
return { ...state, rollupChildren: action.payload };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_DATA":
return { ...state, data: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useCategoryZoom() {
const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(
async (categoryId: number | null, includeChildren: boolean, dateFrom: string, dateTo: string) => {
if (categoryId === null) return;
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const data = await getCategoryZoom(categoryId, dateFrom, dateTo, includeChildren);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: data });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
},
[],
);
useEffect(() => {
fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
const setCategory = useCallback((id: number | null) => {
dispatch({ type: "SET_CATEGORY", payload: id });
}, []);
const setRollupChildren = useCallback((flag: boolean) => {
dispatch({ type: "TOGGLE_ROLLUP", payload: flag });
}, []);
const refetch = useCallback(() => {
fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
return { ...state, setCategory, setRollupChildren, refetch, from, to };
}

View file

@ -1,47 +0,0 @@
import { describe, it, expect } from "vitest";
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
describe("useCompare helpers", () => {
describe("previousMonth", () => {
it("goes back one month within the same year", () => {
expect(previousMonth(2026, 3)).toEqual({ year: 2026, month: 2 });
expect(previousMonth(2026, 12)).toEqual({ year: 2026, month: 11 });
});
it("wraps around January to December of previous year", () => {
expect(previousMonth(2026, 1)).toEqual({ year: 2025, month: 12 });
});
});
describe("defaultReferencePeriod", () => {
it("returns the month before the given date", () => {
expect(defaultReferencePeriod(new Date(2026, 3, 15))).toEqual({ year: 2026, month: 3 });
});
it("wraps around when today is in January", () => {
expect(defaultReferencePeriod(new Date(2026, 0, 10))).toEqual({ year: 2025, month: 12 });
});
it("handles the last day of a month", () => {
expect(defaultReferencePeriod(new Date(2026, 6, 31))).toEqual({ year: 2026, month: 6 });
});
});
describe("comparisonMeta", () => {
it("MoM returns the previous month", () => {
expect(comparisonMeta("mom", 2026, 3)).toEqual({ previousYear: 2026, previousMonth: 2 });
});
it("MoM wraps around January", () => {
expect(comparisonMeta("mom", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 12 });
});
it("YoY returns the same month in the previous year", () => {
expect(comparisonMeta("yoy", 2026, 3)).toEqual({ previousYear: 2025, previousMonth: 3 });
});
it("YoY for January stays on January of previous year", () => {
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
});
});
});

View file

@ -1,145 +0,0 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CategoryDelta } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
export type CompareMode = "actual" | "budget";
export type CompareSubMode = "mom" | "yoy";
interface State {
mode: CompareMode;
subMode: CompareSubMode;
year: number;
month: number;
rows: CategoryDelta[];
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_MODE"; payload: CompareMode }
| { type: "SET_SUB_MODE"; payload: CompareSubMode }
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ERROR"; payload: string };
/**
* Wrap-around helper: returns (year, month) shifted back by one month.
* Example: previousMonth(2026, 1) -> { year: 2025, month: 12 }.
*/
export function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
/**
* Default reference period for the Compare report: the month preceding `today`.
* Exported for unit tests.
*/
export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } {
return previousMonth(today.getFullYear(), today.getMonth() + 1);
}
/**
* Returns the comparison meta for a given subMode + reference period.
* - MoM: previous month vs current month
* - YoY: same month previous year vs current year
*/
export function comparisonMeta(
subMode: CompareSubMode,
year: number,
month: number,
): { previousYear: number; previousMonth: number } {
if (subMode === "mom") {
const prev = previousMonth(year, month);
return { previousYear: prev.year, previousMonth: prev.month };
}
return { previousYear: year - 1, previousMonth: month };
}
const defaultRef = defaultReferencePeriod();
const initialState: State = {
mode: "actual",
subMode: "mom",
year: defaultRef.year,
month: defaultRef.month,
rows: [],
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_MODE":
return { ...state, mode: action.payload };
case "SET_SUB_MODE":
return { ...state, subMode: action.payload };
case "SET_REFERENCE_PERIOD":
return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useCompare() {
const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const rows =
subMode === "mom"
? await getCompareMonthOverMonth(year, month)
: await getCompareYearOverYear(year);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
},
[],
);
useEffect(() => {
fetch(state.mode, state.subMode, state.year, state.month);
}, [fetch, state.mode, state.subMode, state.year, state.month]);
// When the URL period changes, align the reference month with `to`.
// The explicit dropdown remains the primary selector — this effect only
// keeps the two in sync when the user navigates via PeriodSelector.
useEffect(() => {
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
if (y !== state.year || m !== state.month) {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]);
const setMode = useCallback((m: CompareMode) => {
dispatch({ type: "SET_MODE", payload: m });
}, []);
const setSubMode = useCallback((s: CompareSubMode) => {
dispatch({ type: "SET_SUB_MODE", payload: s });
}, []);
const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []);
return { ...state, setMode, setSubMode, setReferencePeriod, from, to };
}

View file

@ -1,44 +0,0 @@
import { describe, it, expect } from "vitest";
import {
feedbackReducer,
initialFeedbackState,
type FeedbackState,
} from "./useFeedback";
describe("feedbackReducer", () => {
it("starts in idle with no error", () => {
expect(initialFeedbackState).toEqual({ status: "idle", errorCode: null });
});
it("transitions idle → sending on SEND_START", () => {
const next = feedbackReducer(initialFeedbackState, { type: "SEND_START" });
expect(next).toEqual({ status: "sending", errorCode: null });
});
it("clears a previous error code when re-sending", () => {
const prev: FeedbackState = { status: "error", errorCode: "rate_limit" };
const next = feedbackReducer(prev, { type: "SEND_START" });
expect(next.errorCode).toBeNull();
});
it("transitions sending → success", () => {
const prev: FeedbackState = { status: "sending", errorCode: null };
const next = feedbackReducer(prev, { type: "SEND_SUCCESS" });
expect(next).toEqual({ status: "success", errorCode: null });
});
it("transitions sending → error and records the code", () => {
const prev: FeedbackState = { status: "sending", errorCode: null };
const next = feedbackReducer(prev, {
type: "SEND_ERROR",
code: "network_error",
});
expect(next).toEqual({ status: "error", errorCode: "network_error" });
});
it("RESET returns to the initial state regardless of prior state", () => {
const prev: FeedbackState = { status: "error", errorCode: "invalid" };
const next = feedbackReducer(prev, { type: "RESET" });
expect(next).toEqual(initialFeedbackState);
});
});

View file

@ -1,68 +0,0 @@
import { useCallback, useReducer } from "react";
import {
sendFeedback,
type FeedbackContext,
type FeedbackErrorCode,
isFeedbackErrorCode,
} from "../services/feedbackService";
export type FeedbackStatus = "idle" | "sending" | "success" | "error";
export interface FeedbackState {
status: FeedbackStatus;
errorCode: FeedbackErrorCode | null;
}
export type FeedbackAction =
| { type: "SEND_START" }
| { type: "SEND_SUCCESS" }
| { type: "SEND_ERROR"; code: FeedbackErrorCode }
| { type: "RESET" };
export const initialFeedbackState: FeedbackState = {
status: "idle",
errorCode: null,
};
export function feedbackReducer(
_state: FeedbackState,
action: FeedbackAction,
): FeedbackState {
switch (action.type) {
case "SEND_START":
return { status: "sending", errorCode: null };
case "SEND_SUCCESS":
return { status: "success", errorCode: null };
case "SEND_ERROR":
return { status: "error", errorCode: action.code };
case "RESET":
return initialFeedbackState;
}
}
export interface SubmitArgs {
content: string;
userId?: string | null;
context?: FeedbackContext;
}
export function useFeedback() {
const [state, dispatch] = useReducer(feedbackReducer, initialFeedbackState);
const submit = useCallback(async (args: SubmitArgs) => {
dispatch({ type: "SEND_START" });
try {
await sendFeedback(args);
dispatch({ type: "SEND_SUCCESS" });
} catch (e) {
const code: FeedbackErrorCode = isFeedbackErrorCode(e)
? e
: "network_error";
dispatch({ type: "SEND_ERROR", code });
}
}, []);
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
return { state, submit, reset };
}

View file

@ -1,68 +0,0 @@
import { useReducer, useEffect, useRef, useCallback } from "react";
import type { HighlightsData } from "../shared/types";
import { getHighlights } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
interface State {
data: HighlightsData | null;
windowDays: 30 | 60 | 90;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_DATA"; payload: HighlightsData }
| { type: "SET_ERROR"; payload: string }
| { type: "SET_WINDOW_DAYS"; payload: 30 | 60 | 90 };
const initialState: State = {
data: null,
windowDays: 30,
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_DATA":
return { ...state, data: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_WINDOW_DAYS":
return { ...state, windowDays: action.payload };
default:
return state;
}
}
export function useHighlights() {
const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(async (windowDays: 30 | 60 | 90, referenceDate: string) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const data = await getHighlights(windowDays, referenceDate);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: data });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
useEffect(() => {
fetch(state.windowDays, to);
}, [fetch, state.windowDays, to]);
const setWindowDays = useCallback((d: 30 | 60 | 90) => {
dispatch({ type: "SET_WINDOW_DAYS", payload: d });
}, []);
return { ...state, setWindowDays, from, to };
}

View file

@ -1,98 +0,0 @@
import { useCallback, useEffect, useReducer } from "react";
import {
Edition,
LicenseInfo,
checkEntitlement as checkEntitlementCmd,
getEdition,
readLicense,
storeLicense,
} from "../services/licenseService";
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
interface LicenseState {
status: LicenseStatus;
edition: Edition;
info: LicenseInfo | null;
error: string | null;
}
type LicenseAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
| { type: "VALIDATE_START" }
| { type: "VALIDATE_DONE"; info: LicenseInfo }
| { type: "ERROR"; error: string };
const initialState: LicenseState = {
status: "idle",
edition: "free",
info: null,
error: null,
};
function reducer(state: LicenseState, action: LicenseAction): LicenseState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return {
status: "ready",
edition: action.edition,
info: action.info,
error: null,
};
case "VALIDATE_START":
return { ...state, status: "validating", error: null };
case "VALIDATE_DONE":
return {
status: "ready",
edition: action.info.edition,
info: action.info,
error: null,
};
case "ERROR":
return { ...state, status: "error", error: action.error };
}
}
export function useLicense() {
const [state, dispatch] = useReducer(reducer, initialState);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
const [edition, info] = await Promise.all([getEdition(), readLicense()]);
dispatch({ type: "LOAD_DONE", edition, info });
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const submitKey = useCallback(async (key: string) => {
dispatch({ type: "VALIDATE_START" });
try {
const info = await storeLicense(key);
dispatch({ type: "VALIDATE_DONE", info });
return { ok: true as const, info };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
dispatch({ type: "ERROR", error: message });
return { ok: false as const, error: message };
}
}, []);
const checkEntitlement = useCallback(
(feature: string) => checkEntitlementCmd(feature),
[],
);
useEffect(() => {
void refresh();
}, [refresh]);
return { state, refresh, submitKey, checkEntitlement };
}

213
src/hooks/useReports.ts Normal file
View file

@ -0,0 +1,213 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
ReportTab,
DashboardPeriod,
MonthlyTrendItem,
CategoryBreakdownItem,
CategoryOverTimeData,
BudgetVsActualRow,
PivotConfig,
PivotResult,
} from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
interface ReportsState {
tab: ReportTab;
period: DashboardPeriod;
customDateFrom: string;
customDateTo: string;
sourceId: number | null;
categoryType: CategoryTypeFilter;
monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetYear: number;
budgetMonth: number;
budgetVsActual: BudgetVsActualRow[];
pivotConfig: PivotConfig;
pivotResult: PivotResult;
isLoading: boolean;
error: string | null;
}
type ReportsAction =
| { type: "SET_TAB"; payload: ReportTab }
| { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
| { type: "SET_SOURCE_ID"; payload: number | null }
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const initialState: ReportsState = {
tab: "trends",
period: "6months",
customDateFrom: monthStartStr,
customDateTo: todayStr,
sourceId: null,
categoryType: "expense",
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
budgetVsActual: [],
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
isLoading: false,
error: null,
};
function reducer(state: ReportsState, action: ReportsAction): ReportsState {
switch (action.type) {
case "SET_TAB":
return { ...state, tab: action.payload };
case "SET_PERIOD":
return { ...state, period: action.payload };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_MONTHLY_TRENDS":
return { ...state, monthlyTrends: action.payload, isLoading: false };
case "SET_CATEGORY_SPENDING":
return { ...state, categorySpending: action.payload, isLoading: false };
case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_BUDGET_VS_ACTUAL":
return { ...state, budgetVsActual: action.payload, isLoading: false };
case "SET_PIVOT_CONFIG":
return { ...state, pivotConfig: action.payload };
case "SET_PIVOT_RESULT":
return { ...state, pivotResult: action.payload, isLoading: false };
case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
case "SET_SOURCE_ID":
return { ...state, sourceId: action.payload };
case "SET_CATEGORY_TYPE":
return { ...state, categoryType: action.payload };
default:
return state;
}
}
export function useReports() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetchData = useCallback(async (
tab: ReportTab,
period: DashboardPeriod,
budgetYear: number,
budgetMonth: number,
customFrom?: string,
customTo?: string,
pivotCfg?: PivotConfig,
srcId?: number | null,
catType?: CategoryTypeFilter,
) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
switch (tab) {
case "trends": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
break;
}
case "byCategory": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
break;
}
case "overTime": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
break;
}
case "budgetVsActual": {
const data = await getBudgetVsActualData(budgetYear, budgetMonth);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
break;
}
case "dynamic": {
if (!pivotCfg || (pivotCfg.rows.length === 0 && pivotCfg.columns.length === 0) || pivotCfg.values.length === 0) {
dispatch({ type: "SET_PIVOT_RESULT", payload: { rows: [], columnValues: [], dimensionLabels: {} } });
break;
}
const data = await getDynamicReportData(pivotCfg);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_PIVOT_RESULT", payload: data });
break;
}
}
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType, fetchData]);
const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab });
}, []);
const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period });
}, []);
const setBudgetMonth = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
}, []);
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
const setPivotConfig = useCallback((config: PivotConfig) => {
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
}, []);
const setSourceId = useCallback((id: number | null) => {
dispatch({ type: "SET_SOURCE_ID", payload: id });
}, []);
const setCategoryType = useCallback((catType: CategoryTypeFilter) => {
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
}, []);
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType };
}

View file

@ -1,53 +0,0 @@
import { describe, it, expect } from "vitest";
import { resolveReportsPeriod } from "./useReportsPeriod";
describe("resolveReportsPeriod", () => {
const fixedToday = new Date("2026-04-14T12:00:00Z");
it("defaults to current civil year when no URL params are set", () => {
const result = resolveReportsPeriod(null, null, null, fixedToday);
expect(result.from).toBe("2026-01-01");
expect(result.to).toBe("2026-12-31");
expect(result.period).toBe("custom");
});
it("restores state from bookmarked from/to params", () => {
const result = resolveReportsPeriod("2025-03-01", "2025-06-30", null, fixedToday);
expect(result.from).toBe("2025-03-01");
expect(result.to).toBe("2025-06-30");
expect(result.period).toBe("custom");
});
it("keeps period=yearly alongside explicit from/to", () => {
const result = resolveReportsPeriod("2024-01-01", "2024-12-31", "year", fixedToday);
expect(result.period).toBe("year");
});
it("ignores malformed dates and falls back to the civil year", () => {
const result = resolveReportsPeriod("not-a-date", "also-not", null, fixedToday);
expect(result.from).toBe("2026-01-01");
expect(result.to).toBe("2026-12-31");
expect(result.period).toBe("custom");
});
it("resolves preset period values without from/to", () => {
const result = resolveReportsPeriod(null, null, "6months", fixedToday);
expect(result.period).toBe("6months");
expect(result.from).toBeTruthy();
expect(result.to).toBeTruthy();
});
it("rejects an invalid period string and falls back to civil year custom", () => {
const result = resolveReportsPeriod(null, null, "bogus", fixedToday);
expect(result.period).toBe("custom");
expect(result.from).toBe("2026-01-01");
});
it("treats `all` as a preset with empty range (service handles the clauses)", () => {
const result = resolveReportsPeriod(null, null, "all", fixedToday);
expect(result.period).toBe("all");
// Fallback civil year when computeDateRange returns empty
expect(result.from).toBe("2026-01-01");
expect(result.to).toBe("2026-12-31");
});
});

View file

@ -1,119 +0,0 @@
import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type { DashboardPeriod } from "../shared/types";
import { computeDateRange } from "../utils/dateRange";
const VALID_PERIODS: readonly DashboardPeriod[] = [
"month",
"3months",
"6months",
"year",
"12months",
"all",
"custom",
];
function isValidPeriod(p: string | null): p is DashboardPeriod {
return p !== null && (VALID_PERIODS as readonly string[]).includes(p);
}
function isValidIsoDate(s: string | null): s is string {
return !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
}
function currentYearRange(today: Date = new Date()): { from: string; to: string } {
const year = today.getFullYear();
return { from: `${year}-01-01`, to: `${year}-12-31` };
}
/**
* Pure resolver used by the hook and unit tests. Exposed to keep the core
* logic hookless and testable without rendering a router.
*/
export function resolveReportsPeriod(
rawFrom: string | null,
rawTo: string | null,
rawPeriod: string | null,
today: Date = new Date(),
): { from: string; to: string; period: DashboardPeriod } {
if (isValidIsoDate(rawFrom) && isValidIsoDate(rawTo)) {
const p = isValidPeriod(rawPeriod) ? rawPeriod : "custom";
return { from: rawFrom, to: rawTo, period: p };
}
if (isValidPeriod(rawPeriod) && rawPeriod !== "custom") {
const range = computeDateRange(rawPeriod);
const { from: defaultFrom, to: defaultTo } = currentYearRange(today);
return {
from: range.dateFrom ?? defaultFrom,
to: range.dateTo ?? defaultTo,
period: rawPeriod,
};
}
const { from, to } = currentYearRange(today);
return { from, to, period: "custom" };
}
export interface UseReportsPeriodResult {
from: string;
to: string;
period: DashboardPeriod;
setPeriod: (period: DashboardPeriod) => void;
setCustomDates: (from: string, to: string) => void;
}
/**
* Reads/writes the active reporting period via the URL query string so it is
* bookmarkable and shared across the four report sub-routes.
*
* Defaults to the current civil year (Jan 1 Dec 31).
*/
export function useReportsPeriod(): UseReportsPeriodResult {
const [searchParams, setSearchParams] = useSearchParams();
const rawPeriod = searchParams.get("period");
const rawFrom = searchParams.get("from");
const rawTo = searchParams.get("to");
const { from, to, period } = useMemo(
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
[rawPeriod, rawFrom, rawTo],
);
const setPeriod = useCallback(
(next: DashboardPeriod) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
if (next === "custom") {
params.set("period", "custom");
} else {
params.set("period", next);
params.delete("from");
params.delete("to");
}
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
const setCustomDates = useCallback(
(nextFrom: string, nextTo: string) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set("period", "custom");
params.set("from", nextFrom);
params.set("to", nextTo);
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
return { from, to, period, setPeriod, setCustomDates };
}

View file

@ -1,81 +0,0 @@
import { useReducer, useEffect, useRef, useCallback } from "react";
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
export type TrendsSubView = "global" | "byCategory";
interface State {
subView: TrendsSubView;
monthlyTrends: MonthlyTrendItem[];
categoryOverTime: CategoryOverTimeData;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_SUBVIEW"; payload: TrendsSubView }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_ERROR"; payload: string };
const initialState: State = {
subView: "global",
monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_SUBVIEW":
return { ...state, subView: action.payload };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_TRENDS":
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useTrends() {
const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
if (subView === "global") {
const data = await getMonthlyTrends(dateFrom, dateTo);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
}
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
useEffect(() => {
fetch(state.subView, from, to);
}, [fetch, state.subView, from, to]);
const setSubView = useCallback((sv: TrendsSubView) => {
dispatch({ type: "SET_SUBVIEW", payload: sv });
}, []);
return { ...state, setSubView, from, to };
}

View file

@ -358,10 +358,7 @@
"period": "Period", "period": "Period",
"byCategory": "Expenses by Category", "byCategory": "Expenses by Category",
"overTime": "Category Over Time", "overTime": "Category Over Time",
"trends": { "trends": "Monthly Trends",
"subviewGlobal": "Global flow",
"subviewByCategory": "By category"
},
"budgetVsActual": "Budget vs Actual", "budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top", "subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom", "subtotalsOnBottom": "Subtotals on bottom",
@ -384,93 +381,33 @@
"noData": "No budget or transaction data for this period.", "noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for" "titlePrefix": "Budget vs Actual for"
}, },
"dynamic": "Dynamic Report",
"export": "Export", "export": "Export",
"pivot": {
"availableFields": "Available Fields",
"rows": "Rows",
"columns": "Columns",
"filters": "Filters",
"values": "Values",
"addTo": "Add to...",
"year": "Year",
"month": "Month", "month": "Month",
"viewMode": { "categoryType": "Type",
"chart": "Chart", "level1": "Category (Level 1)",
"table": "Table" "level2": "Category (Level 2)",
}, "level3": "Category (Level 3)",
"hub": { "periodic": "Periodic Amount",
"title": "Reports", "ytd": "Year-to-Date (YTD)",
"explore": "Explore", "subtotal": "Subtotal",
"highlights": "Highlights", "total": "Total",
"highlightsDescription": "What moved this month", "viewTable": "Table",
"trends": "Trends", "viewChart": "Chart",
"trendsDescription": "Where you're heading over 12 months", "viewBoth": "Both",
"compare": "Compare", "noConfig": "Add fields to generate the report",
"compareDescription": "Compare a reference month against previous month, previous year, or budget", "noData": "No data for this configuration",
"categoryZoom": "Category Analysis", "fullscreen": "Full screen",
"categoryZoomDescription": "Zoom in on a single category", "exitFullscreen": "Exit full screen",
"cartes": "Cards", "rightClickExclude": "Right-click to exclude"
"cartesDescription": "KPI dashboard with sparklines, top movers, budget adherence, and seasonality"
},
"compare": {
"modeActual": "Actual vs actual",
"modeBudget": "Actual vs budget",
"subModeMoM": "Previous month",
"subModeYoY": "Previous year",
"subModeAria": "Comparison period",
"referenceMonth": "Reference month"
},
"cartes": {
"kpiSectionAria": "Key indicators for the reference month",
"income": "Income",
"expenses": "Expenses",
"net": "Net balance",
"savingsRate": "Savings rate",
"deltaMoMLabel": "vs last month",
"deltaYoYLabel": "vs last year",
"flowChartTitle": "Income vs expenses — last 12 months",
"topMoversUp": "Biggest increases",
"topMoversDown": "Biggest decreases",
"budgetAdherenceTitle": "Budget adherence",
"budgetAdherenceSubtitle": "{{score}} of budgeted categories on target",
"budgetAdherenceEmpty": "No budgeted categories this month",
"budgetAdherenceWorst": "Worst overruns",
"seasonalityTitle": "Seasonality",
"seasonalityEmpty": "Not enough history for this month",
"seasonalityAverage": "Average",
"seasonalityDeviation": "{{pct}} vs average"
},
"category": {
"selectCategory": "Select a category",
"includeSubcategories": "Include subcategories",
"directOnly": "Direct only",
"breakdown": "Total",
"evolution": "Evolution",
"transactions": "Transactions"
},
"keyword": {
"addFromTransaction": "Add as keyword",
"dialogTitle": "New keyword",
"willMatch": "Will also match",
"nMatches_one": "{{count}} transaction matched",
"nMatches_other": "{{count}} transactions matched",
"applyAndRecategorize": "Apply and recategorize",
"applyToHidden": "Also apply to {{count}} non-displayed transactions",
"tooShort": "Minimum {{min}} characters",
"tooLong": "Maximum {{max}} characters",
"alreadyExists": "This keyword already exists for another category. Reassign?"
},
"highlights": {
"balances": "Balances",
"netBalanceCurrent": "This month",
"netBalanceYtd": "Year to date",
"topMovers": "Top movers",
"topTransactions": "Top recent transactions",
"category": "Category",
"previousAmount": "Previous",
"currentAmount": "Current",
"variationAbs": "Delta ($)",
"variationPct": "Delta (%)",
"vsLastMonth": "vs. last month",
"windowDays30": "30 days",
"windowDays60": "60 days",
"windowDays90": "90 days"
},
"empty": {
"noData": "No data for this period",
"importCta": "Import a statement"
}, },
"help": { "help": {
"title": "How to use Reports", "title": "How to use Reports",
@ -478,7 +415,8 @@
"Switch between Trends, By Category, and Over Time views using the tabs", "Switch between Trends, By Category, and Over Time views using the tabs",
"Use the period selector to adjust the time range for all charts", "Use the period selector to adjust the time range for all charts",
"Monthly Trends shows your income and expenses over time", "Monthly Trends shows your income and expenses over time",
"Category Over Time tracks how spending in each category evolves" "Category Over Time tracks how spending in each category evolves",
"Dynamic Report lets you build custom pivot tables by assigning dimensions to rows, columns, and filters"
] ]
} }
}, },
@ -801,29 +739,32 @@
}, },
"reports": { "reports": {
"title": "Reports", "title": "Reports",
"overview": "A hub with a live highlights panel plus four dedicated sub-reports (Highlights, Trends, Compare, Category Zoom). Every page shares a bookmarkable period via the URL query string.", "overview": "Visualize your financial data with interactive charts and compare your budget plan against actual spending.",
"features": [ "features": [
"Hub: compact highlights panel + 4 navigation cards", "Monthly Trends: income vs. expenses over time (bar chart)",
"Highlights: current month and YTD balances with sparklines, top movers vs. last month, top recent transactions (30/60/90 day window)", "Expenses by Category: spending breakdown (pie chart)",
"Trends: global flow (income vs. expenses) and by-category evolution with a chart/table toggle", "Category Over Time: track how each category evolves (line chart)",
"Compare: Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget", "Budget vs Actual: monthly and year-to-date comparison table",
"Category Zoom: single-category drill-down with donut, monthly evolution, and filterable transaction table; auto-rollup of subcategories", "Dynamic Report: customizable pivot table",
"Contextual keyword editing: right-click a transaction row to add its description as a keyword with a live preview of the matches",
"SVG patterns (lines, dots, crosshatch) to distinguish categories", "SVG patterns (lines, dots, crosshatch) to distinguish categories",
"View mode preference (chart vs. table) persisted per report section" "Context menu (right-click) to hide a category or view its transactions",
"Transaction detail by category with sortable columns (date, description, amount)",
"Toggle to show or hide amounts in transaction detail"
], ],
"steps": [ "steps": [
"Open /reports to see the highlights panel and four navigation cards", "Use the tabs to switch between Trends, By Category, Over Time, and Budget vs Actual views",
"Adjust the period with the period selector — it is mirrored in the URL and shared with every sub-report", "Adjust the time period using the period selector",
"Click a card or a sub-route link to open the corresponding report", "Right-click a category in any chart to hide it or view its transaction details",
"Toggle chart vs. table on any sub-report — your choice is remembered", "Hidden categories appear as dismissible chips above the chart — click them to show again",
"Right-click any transaction row in the category zoom, highlights list, or transactions page to add a keyword", "In Budget vs Actual, toggle between Monthly and Year-to-Date views",
"In the keyword dialog, review the preview of matching transactions and confirm to apply" "In the category detail, click a column header to sort transactions",
"Use the eye icon in the detail view to show or hide the amounts column"
], ],
"tips": [ "tips": [
"Copy the URL to share a specific period + report state", "Hidden categories are remembered while you stay on the page — click Show All to reset",
"Keywords must be 264 characters long", "The period selector applies to all chart tabs simultaneously",
"The Category Zoom is protected against malformed category trees: a parent_id cycle cannot freeze the app" "Budget vs Actual shows dollar and percentage variance for each category",
"SVG patterns help colorblind users distinguish categories in charts"
] ]
}, },
"settings": { "settings": {
@ -836,8 +777,7 @@
"Application logs viewable with level filters, copy, and clear", "Application logs viewable with level filters, copy, and clear",
"Data export (transactions, categories, or both) in JSON or CSV format", "Data export (transactions, categories, or both) in JSON or CSV format",
"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)"
], ],
"steps": [ "steps": [
"Click User Guide to access the full documentation", "Click User Guide to access the full documentation",
@ -845,8 +785,7 @@
"View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear", "View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear",
"Use the Data Management section to export or import your data", "Use the Data Management section to export or import your data",
"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"
], ],
"tips": [ "tips": [
"Updates only replace the app binary — your database is never modified", "Updates only replace the app binary — your database is never modified",
@ -854,8 +793,7 @@
"Export regularly to keep a backup of your data", "Export regularly to keep a backup of your data",
"The user guide can be printed or exported to PDF via the Print button", "The user guide can be printed or exported to PDF via the Print button",
"Logs persist for the session — they survive a page refresh", "Logs persist for the session — they survive a page refresh",
"If you encounter an issue, copy the logs and attach them to your report", "If you encounter an issue, copy the logs and attach them to your report"
"Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
] ]
} }
}, },
@ -909,78 +847,6 @@
"language": "Language", "language": "Language",
"total": "Total", "total": "Total",
"darkMode": "Dark mode", "darkMode": "Dark mode",
"lightMode": "Light mode", "lightMode": "Light mode"
"close": "Close",
"underConstruction": "Under construction"
},
"license": {
"title": "License",
"currentEdition": "Current edition",
"expiresAt": "Expires on",
"enterKey": "Enter a license key",
"keyPlaceholder": "SR-BASE-...",
"activate": "Activate",
"purchase": "Buy Simpl'Result",
"editions": {
"free": "Free",
"base": "Base",
"premium": "Premium"
},
"removeLicense": "Remove license",
"machines": {
"title": "Machines",
"count": "{{count}}/{{limit}} machines activated",
"activating": "Activating...",
"activated": "Activated",
"notActivated": "Not activated",
"deactivate": "Deactivate",
"deactivating": "Deactivating...",
"activateError": "Activation failed",
"thisMachine": "This machine",
"noMachines": "No machines activated"
}
},
"account": {
"title": "Maximus Account",
"optional": "Optional",
"description": "Sign in to access Premium features (web version, sync). The account is only required for Premium features.",
"signIn": "Sign in",
"signOut": "Sign out",
"connected": "Connected",
"tokenStore": {
"fallback": {
"title": "Tokens stored in plaintext fallback",
"description": "Your authentication tokens are currently stored in a local file protected by filesystem permissions. For stronger protection via the OS keychain, make sure a keyring service is running (GNOME Keyring, KWallet, or equivalent)."
}
}
},
"feedback": {
"button": "Send feedback",
"logsHeading": "Recent logs:",
"dialog": {
"title": "Your feedback",
"placeholder": "Describe your suggestion, comment, or issue...",
"submit": "Send",
"sending": "Sending...",
"cancel": "Cancel"
},
"checkbox": {
"context": "Include navigation context (page, theme, viewport, version)",
"logs": "Include recent error logs",
"identify": "Identify me with my Maximus account"
},
"toast": {
"success": "Thank you for your feedback",
"error": {
"429": "Too many feedbacks sent recently. Try again later.",
"400": "Invalid feedback. Check the content.",
"generic": "Error while sending. Try again later."
}
},
"consent": {
"title": "Feedback submission",
"body": "Your feedback will be sent to feedback.lacompagniemaximus.com so we can improve the app. This requires an internet connection and is an exception to the app's 100% local operation.",
"accept": "I agree"
}
} }
} }

View file

@ -358,11 +358,8 @@
"period": "Période", "period": "Période",
"byCategory": "Dépenses par catégorie", "byCategory": "Dépenses par catégorie",
"overTime": "Catégories dans le temps", "overTime": "Catégories dans le temps",
"trends": { "trends": "Tendances mensuelles",
"subviewGlobal": "Flux global", "budgetVsActual": "Budget vs R\u00e9el",
"subviewByCategory": "Par catégorie"
},
"budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut", "subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas", "subtotalsOnBottom": "Sous-totaux en bas",
"detail": { "detail": {
@ -379,98 +376,38 @@
"bva": { "bva": {
"monthly": "Mensuel", "monthly": "Mensuel",
"ytd": "Cumul annuel", "ytd": "Cumul annuel",
"dollarVar": "$ Écart", "dollarVar": "$ \u00c9cart",
"pctVar": "% Écart", "pctVar": "% \u00c9cart",
"noData": "Aucune donnée de budget ou de transaction pour cette période.", "noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
"titlePrefix": "Budget vs Réel pour le mois de" "titlePrefix": "Budget vs Réel pour le mois de"
}, },
"dynamic": "Rapport dynamique",
"export": "Exporter", "export": "Exporter",
"pivot": {
"availableFields": "Champs disponibles",
"rows": "Lignes",
"columns": "Colonnes",
"filters": "Filtres",
"values": "Valeurs",
"addTo": "Ajouter à...",
"year": "Année",
"month": "Mois", "month": "Mois",
"viewMode": { "categoryType": "Type",
"chart": "Graphique", "level1": "Catégorie (Niveau 1)",
"table": "Tableau" "level2": "Catégorie (Niveau 2)",
}, "level3": "Catégorie (Niveau 3)",
"hub": { "periodic": "Montant périodique",
"title": "Rapports", "ytd": "Cumul annuel (YTD)",
"explore": "Explorer", "subtotal": "Sous-total",
"highlights": "Faits saillants", "total": "Total",
"highlightsDescription": "Ce qui a bougé ce mois-ci", "viewTable": "Tableau",
"trends": "Tendances", "viewChart": "Graphique",
"trendsDescription": "Où vous allez sur 12 mois", "viewBoth": "Les deux",
"compare": "Comparables", "noConfig": "Ajoutez des champs pour générer le rapport",
"compareDescription": "Comparer un mois de référence au précédent, à l'année passée ou au budget", "noData": "Aucune donnée pour cette configuration",
"categoryZoom": "Analyse par catégorie", "fullscreen": "Plein écran",
"categoryZoomDescription": "Zoom sur une catégorie", "exitFullscreen": "Quitter plein écran",
"cartes": "Cartes", "rightClickExclude": "Clic-droit pour exclure"
"cartesDescription": "Tableau de bord KPI, sparklines, top mouvements, budget et saisonnalité"
},
"compare": {
"modeActual": "Réel vs réel",
"modeBudget": "Réel vs budget",
"subModeMoM": "Mois précédent",
"subModeYoY": "Année précédente",
"subModeAria": "Période de comparaison",
"referenceMonth": "Mois de référence"
},
"cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence",
"income": "Revenus",
"expenses": "Dépenses",
"net": "Solde net",
"savingsRate": "Taux d'épargne",
"deltaMoMLabel": "vs mois précédent",
"deltaYoYLabel": "vs l'an dernier",
"flowChartTitle": "Revenus vs dépenses — 12 derniers mois",
"topMoversUp": "Catégories en hausse",
"topMoversDown": "Catégories en baisse",
"budgetAdherenceTitle": "Respect du budget",
"budgetAdherenceSubtitle": "{{score}} des catégories avec budget sont dans la cible",
"budgetAdherenceEmpty": "Aucune catégorie avec budget ce mois-ci",
"budgetAdherenceWorst": "Pires dépassements",
"seasonalityTitle": "Saisonnalité",
"seasonalityEmpty": "Pas assez d'historique pour ce mois",
"seasonalityAverage": "Moyenne",
"seasonalityDeviation": "{{pct}} par rapport à la moyenne"
},
"category": {
"selectCategory": "Choisir une catégorie",
"includeSubcategories": "Inclure les sous-catégories",
"directOnly": "Directe seulement",
"breakdown": "Total",
"evolution": "Évolution",
"transactions": "Transactions"
},
"keyword": {
"addFromTransaction": "Ajouter comme mot-clé",
"dialogTitle": "Nouveau mot-clé",
"willMatch": "Matchera aussi",
"nMatches_one": "{{count}} transaction matchée",
"nMatches_other": "{{count}} transactions matchées",
"applyAndRecategorize": "Appliquer et recatégoriser",
"applyToHidden": "Appliquer aussi aux {{count}} transactions non affichées",
"tooShort": "Minimum {{min}} caractères",
"tooLong": "Maximum {{max}} caractères",
"alreadyExists": "Ce mot-clé existe déjà pour une autre catégorie. Remplacer ?"
},
"highlights": {
"balances": "Soldes",
"netBalanceCurrent": "Ce mois-ci",
"netBalanceYtd": "Cumul annuel",
"topMovers": "Top mouvements",
"topTransactions": "Plus grosses transactions récentes",
"category": "Catégorie",
"previousAmount": "Précédent",
"currentAmount": "Courant",
"variationAbs": "Écart ($)",
"variationPct": "Écart (%)",
"vsLastMonth": "vs mois précédent",
"windowDays30": "30 jours",
"windowDays60": "60 jours",
"windowDays90": "90 jours"
},
"empty": {
"noData": "Aucune donnée pour cette période",
"importCta": "Importer un relevé"
}, },
"help": { "help": {
"title": "Comment utiliser les Rapports", "title": "Comment utiliser les Rapports",
@ -478,7 +415,8 @@
"Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets", "Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets",
"Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques", "Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques",
"Les tendances mensuelles montrent vos revenus et dépenses au fil du temps", "Les tendances mensuelles montrent vos revenus et dépenses au fil du temps",
"Catégories dans le temps suit l'évolution des dépenses par catégorie" "Catégories dans le temps suit l'évolution des dépenses par catégorie",
"Le Rapport dynamique permet de créer des tableaux croisés personnalisés en assignant des dimensions aux lignes, colonnes et filtres"
] ]
} }
}, },
@ -801,29 +739,32 @@
}, },
"reports": { "reports": {
"title": "Rapports", "title": "Rapports",
"overview": "Un hub qui affiche un panneau de faits saillants en direct plus quatre sous-rapports dédiés (Faits saillants, Tendances, Comparables, Zoom catégorie). Chaque page partage une période bookmarkable via la query string de l'URL.", "overview": "Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.",
"features": [ "features": [
"Hub : panneau de faits saillants condensé + 4 cartes de navigation", "Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)",
"Faits saillants : soldes mois courant et cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes (fenêtre 30/60/90 jours)", "Dépenses par catégorie : répartition des dépenses (graphique circulaire)",
"Tendances : flux global (revenus vs dépenses) et évolution par catégorie avec toggle graphique/tableau", "Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)",
"Comparables : Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget", "Budget vs Réel : tableau comparatif mensuel et cumul annuel",
"Zoom catégorie : analyse d'une seule catégorie avec donut, évolution mensuelle et tableau de transactions filtrable ; rollup automatique des sous-catégories", "Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable",
"Édition contextuelle des mots-clés : clic droit sur une ligne de transaction pour ajouter sa description comme mot-clé avec prévisualisation en direct des matches",
"Motifs SVG (lignes, points, hachures) pour distinguer les catégories", "Motifs SVG (lignes, points, hachures) pour distinguer les catégories",
"Préférence chart/table mémorisée par section de rapport" "Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions",
"Détail des transactions par catégorie avec tri par colonne (date, description, montant)",
"Toggle pour afficher ou masquer les montants dans le détail des transactions"
], ],
"steps": [ "steps": [
"Ouvrez /reports pour voir le panneau de faits saillants et les quatre cartes de navigation", "Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel",
"Ajustez la période avec le sélecteur — elle est reflétée dans l'URL et partagée avec tous les sous-rapports", "Ajustez la période avec le sélecteur de période",
"Cliquez sur une carte ou un lien pour ouvrir le sous-rapport correspondant", "Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions",
"Basculez graphique/tableau sur n'importe quel sous-rapport — votre choix est mémorisé", "Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher",
"Cliquez droit sur une ligne de transaction dans le zoom catégorie, la liste des faits saillants, ou la page transactions pour ajouter un mot-clé", "Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel",
"Dans le dialog de mot-clé, passez en revue la prévisualisation des transactions qui matchent et confirmez pour appliquer" "Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions",
"Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants"
], ],
"tips": [ "tips": [
"Copiez l'URL pour partager une période et un rapport spécifiques", "Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser",
"Les mots-clés doivent faire entre 2 et 64 caractères", "Le sélecteur de période s'applique à tous les onglets de graphiques simultanément",
"Le Zoom catégorie est protégé contre les arborescences malformées : un cycle parent_id ne peut pas figer l'app" "Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie",
"Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques"
] ]
}, },
"settings": { "settings": {
@ -836,8 +777,7 @@
"Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement", "Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement",
"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"
"Envoi de feedback optionnel vers feedback.lacompagniemaximus.com (exception explicite au fonctionnement 100% local — déclenche une demande de consentement)"
], ],
"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",
@ -845,8 +785,7 @@
"Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez", "Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez",
"Utilisez la section Gestion des données pour exporter ou importer vos données", "Utilisez la section Gestion des données pour exporter ou importer vos données",
"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"
], ],
"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",
@ -854,8 +793,7 @@
"Exportez régulièrement pour garder une sauvegarde de vos données", "Exportez régulièrement pour garder une sauvegarde de vos données",
"Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer", "Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer",
"Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page", "Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page",
"En cas de problème, copiez les journaux et joignez-les à votre signalement", "En cas de problème, copiez les journaux et joignez-les à votre signalement"
"Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
] ]
} }
}, },
@ -909,78 +847,6 @@
"language": "Langue", "language": "Langue",
"total": "Total", "total": "Total",
"darkMode": "Mode sombre", "darkMode": "Mode sombre",
"lightMode": "Mode clair", "lightMode": "Mode clair"
"close": "Fermer",
"underConstruction": "En construction"
},
"license": {
"title": "Licence",
"currentEdition": "Édition actuelle",
"expiresAt": "Expire le",
"enterKey": "Entrer une clé de licence",
"keyPlaceholder": "SR-BASE-...",
"activate": "Activer",
"purchase": "Acheter Simpl'Résultat",
"editions": {
"free": "Gratuite",
"base": "Base",
"premium": "Premium"
},
"removeLicense": "Supprimer la licence",
"machines": {
"title": "Machines",
"count": "{{count}}/{{limit}} machines activées",
"activating": "Activation...",
"activated": "Activée",
"notActivated": "Non activée",
"deactivate": "Désactiver",
"deactivating": "Désactivation...",
"activateError": "Échec de l'activation",
"thisMachine": "Cette machine",
"noMachines": "Aucune machine activée"
}
},
"account": {
"title": "Compte Maximus",
"optional": "Optionnel",
"description": "Connectez-vous pour accéder aux fonctionnalités Premium (version web, synchronisation). Le compte est requis uniquement pour les fonctionnalités Premium.",
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"connected": "Connecté",
"tokenStore": {
"fallback": {
"title": "Stockage des tokens en clair",
"description": "Vos jetons d'authentification sont stockés dans un fichier local protégé par les permissions du système. Pour une protection renforcée via le trousseau du système d'exploitation, vérifiez que le service de trousseau est disponible (GNOME Keyring, KWallet, ou équivalent)."
}
}
},
"feedback": {
"button": "Envoyer un feedback",
"logsHeading": "Logs récents :",
"dialog": {
"title": "Votre avis",
"placeholder": "Décrivez votre suggestion, commentaire ou problème...",
"submit": "Envoyer",
"sending": "Envoi...",
"cancel": "Annuler"
},
"checkbox": {
"context": "Inclure le contexte de navigation (page, thème, écran, version)",
"logs": "Inclure les derniers logs d'erreur",
"identify": "M'identifier avec mon compte Maximus"
},
"toast": {
"success": "Merci pour votre feedback",
"error": {
"429": "Trop de feedbacks envoyés récemment. Réessayez plus tard.",
"400": "Feedback invalide. Vérifiez le contenu.",
"generic": "Erreur lors de l'envoi. Réessayez plus tard."
}
},
"consent": {
"title": "Envoi de feedback",
"body": "Votre feedback sera envoyé à feedback.lacompagniemaximus.com pour que nous puissions améliorer l'application. Cette opération nécessite une connexion Internet et constitue une exception au fonctionnement 100 % local de l'application.",
"accept": "J'accepte"
}
} }
} }

View file

@ -8,7 +8,7 @@ import ProfileFormModal from "../components/profile/ProfileFormModal";
export default function ProfileSelectionPage() { export default function ProfileSelectionPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, switchProfile, updateProfile } = useProfile(); const { profiles, switchProfile } = useProfile();
const [pinProfileId, setPinProfileId] = useState<string | null>(null); const [pinProfileId, setPinProfileId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@ -23,15 +23,8 @@ export default function ProfileSelectionPage() {
} }
}; };
const handlePinSuccess = async (rehashed?: string | null) => { const handlePinSuccess = () => {
if (pinProfileId) { if (pinProfileId) {
if (rehashed) {
try {
await updateProfile(pinProfileId, { pin_hash: rehashed });
} catch {
// Best-effort rehash: don't block profile switch if persistence fails
}
}
switchProfile(pinProfileId); switchProfile(pinProfileId);
setPinProfileId(null); setPinProfileId(null);
} }

View file

@ -1,120 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
import KpiCard from "../components/reports/cards/KpiCard";
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
import TopMoversList from "../components/reports/cards/TopMoversList";
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
import SeasonalityCard from "../components/reports/cards/SeasonalityCard";
import { useCartes } from "../hooks/useCartes";
export default function ReportsCartesPage() {
const { t } = useTranslation();
const {
year,
month,
snapshot,
isLoading,
error,
setReferencePeriod,
period,
setPeriod,
from,
to,
setCustomDates,
} = useCartes();
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.cartes")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{!snapshot ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
) : (
<div className="flex flex-col gap-4">
<section
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3"
aria-label={t("reports.cartes.kpiSectionAria")}
>
<KpiCard
id="income"
title={t("reports.cartes.income")}
kpi={snapshot.kpis.income}
format="currency"
deltaIsBadWhenUp={false}
/>
<KpiCard
id="expenses"
title={t("reports.cartes.expenses")}
kpi={snapshot.kpis.expenses}
format="currency"
deltaIsBadWhenUp={true}
/>
<KpiCard
id="net"
title={t("reports.cartes.net")}
kpi={snapshot.kpis.net}
format="currency"
deltaIsBadWhenUp={false}
/>
<KpiCard
id="savingsRate"
title={t("reports.cartes.savingsRate")}
kpi={snapshot.kpis.savingsRate}
format="percent"
deltaIsBadWhenUp={false}
/>
</section>
<IncomeExpenseOverlayChart flow={snapshot.flow12Months} />
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<TopMoversList movers={snapshot.topMoversUp} direction="up" />
<TopMoversList movers={snapshot.topMoversDown} direction="down" />
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<BudgetAdherenceCard adherence={snapshot.budgetAdherence} />
<SeasonalityCard
seasonality={snapshot.seasonality}
referenceYear={year}
referenceMonth={month}
/>
</section>
</div>
)}
</div>
);
}

View file

@ -1,114 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryZoomHeader from "../components/reports/CategoryZoomHeader";
import CategoryDonutChart from "../components/reports/CategoryDonutChart";
import CategoryEvolutionChart from "../components/reports/CategoryEvolutionChart";
import CategoryTransactionsTable from "../components/reports/CategoryTransactionsTable";
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import { useCategoryZoom } from "../hooks/useCategoryZoom";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import type { RecentTransaction } from "../shared/types";
export default function ReportsCategoryPage() {
const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const {
zoomedCategoryId,
rollupChildren,
data,
isLoading,
error,
setCategory,
setRollupChildren,
refetch,
} = useCategoryZoom();
const [pending, setPending] = useState<RecentTransaction | null>(null);
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const centerLabel = t("reports.category.breakdown");
const centerValue = data
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(data.rollupTotal)
: "—";
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.categoryZoom")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
</div>
<div className="mb-4">
<CategoryZoomHeader
categoryId={zoomedCategoryId}
includeSubcategories={rollupChildren}
onCategoryChange={setCategory}
onIncludeSubcategoriesChange={setRollupChildren}
/>
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{zoomedCategoryId === null ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.category.selectCategory")}
</div>
) : data ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<CategoryDonutChart
byChild={data.byChild}
centerLabel={centerLabel}
centerValue={centerValue}
/>
<CategoryEvolutionChart data={data.monthlyEvolution} />
<div className="lg:col-span-2">
<h3 className="text-sm font-semibold mb-2">{t("reports.category.transactions")}</h3>
<CategoryTransactionsTable
transactions={data.transactions}
onAddKeyword={(tx) => setPending(tx)}
/>
</div>
</div>
) : null}
{pending && (
<AddKeywordDialog
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
initialCategoryId={zoomedCategoryId}
onClose={() => setPending(null)}
onApplied={() => {
setPending(null);
refetch();
}}
/>
)}
</div>
);
}

View file

@ -1,116 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CompareModeTabs from "../components/reports/CompareModeTabs";
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
import ComparePeriodTable from "../components/reports/ComparePeriodTable";
import ComparePeriodChart from "../components/reports/ComparePeriodChart";
import CompareBudgetView from "../components/reports/CompareBudgetView";
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
import { useCompare, comparisonMeta } from "../hooks/useCompare";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
const STORAGE_KEY = "reports-viewmode-compare";
function formatMonthLabel(year: number, month: number, language: string): string {
const date = new Date(year, month - 1, 1);
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(date);
}
export default function ReportsComparePage() {
const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const {
mode,
subMode,
setMode,
setSubMode,
setReferencePeriod,
year,
month,
rows,
isLoading,
error,
} = useCompare();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const { previousYear, previousMonth: prevMonth } = comparisonMeta(subMode, year, month);
const currentLabel =
subMode === "mom" ? formatMonthLabel(year, month, i18n.language) : String(year);
const previousLabel =
subMode === "mom"
? formatMonthLabel(previousYear, prevMonth, i18n.language)
: String(previousYear);
const showActualControls = mode === "actual";
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.compare")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<div className="flex gap-2 items-center flex-wrap">
<CompareModeTabs value={mode} onChange={setMode} />
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<CompareReferenceMonthPicker
year={year}
month={month}
onChange={setReferencePeriod}
/>
{showActualControls && (
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
)}
</div>
{showActualControls && (
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
)}
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{mode === "budget" ? (
<CompareBudgetView year={year} month={month} />
) : viewMode === "chart" ? (
<ComparePeriodChart
rows={rows}
previousLabel={previousLabel}
currentLabel={currentLabel}
/>
) : (
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
)}
</div>
);
}

View file

@ -1,131 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft, Tag } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import HubNetBalanceTile from "../components/reports/HubNetBalanceTile";
import HighlightsTopMoversTable from "../components/reports/HighlightsTopMoversTable";
import HighlightsTopMoversChart from "../components/reports/HighlightsTopMoversChart";
import HighlightsTopTransactionsList from "../components/reports/HighlightsTopTransactionsList";
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
import ContextMenu from "../components/shared/ContextMenu";
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import { useHighlights } from "../hooks/useHighlights";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import type { RecentTransaction } from "../shared/types";
const STORAGE_KEY = "reports-viewmode-highlights";
export default function ReportsHighlightsPage() {
const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { data, isLoading, error, windowDays, setWindowDays } = useHighlights();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
const [pending, setPending] = useState<RecentTransaction | null>(null);
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, tx });
};
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.highlights")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{data && (
<div className="flex flex-col gap-6">
<section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.highlights.balances")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<HubNetBalanceTile
label={t("reports.highlights.netBalanceCurrent")}
amount={data.netBalanceCurrent}
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
/>
<HubNetBalanceTile
label={t("reports.highlights.netBalanceYtd")}
amount={data.netBalanceYtd}
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
/>
</div>
</section>
<section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.highlights.topMovers")}
</h2>
{viewMode === "chart" ? (
<HighlightsTopMoversChart movers={data.topMovers} />
) : (
<HighlightsTopMoversTable movers={data.topMovers} />
)}
</section>
<section>
<HighlightsTopTransactionsList
transactions={data.topTransactions}
windowDays={windowDays}
onWindowChange={setWindowDays}
onContextMenuRow={handleContextMenu}
/>
</section>
</div>
)}
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
header={menu.tx.description}
onClose={() => setMenu(null)}
items={[
{
icon: <Tag size={14} />,
label: t("reports.keyword.addFromTransaction"),
onClick: () => setPending(menu.tx),
},
]}
/>
)}
{pending && (
<AddKeywordDialog
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
onClose={() => setPending(null)}
onApplied={() => setPending(null)}
/>
)}
</div>
);
}

View file

@ -1,79 +1,257 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react"; import { Hash, Table, BarChart3 } from "lucide-react";
import { useReports } from "../hooks/useReports";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
import { getAllSources } from "../services/importSourceService";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import HubReportNavCard from "../components/reports/HubReportNavCard"; import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import { useHighlights } from "../hooks/useHighlights"; import CategoryBarChart from "../components/reports/CategoryBarChart";
import { useReportsPeriod } from "../hooks/useReportsPeriod"; import CategoryTable from "../components/reports/CategoryTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import DynamicReport from "../components/reports/DynamicReport";
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
export default function ReportsPage() { export default function ReportsPage() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType } = useReports();
const { data, isLoading, error } = useHighlights(); const [sources, setSources] = useState<ImportSource[]>([]);
const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; useEffect(() => {
const navCards = [ getAllSources().then(setSources);
{ }, []);
to: `/reports/highlights${preserveSearch}`,
icon: <Sparkles size={24} />, const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
title: t("reports.hub.highlights"), const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
description: t("reports.hub.highlightsDescription"), const [showAmounts, setShowAmounts] = useState(() => localStorage.getItem("reports-show-amounts") === "true");
}, const [viewMode, setViewMode] = useState<"chart" | "table">(() =>
{ (localStorage.getItem("reports-view-mode") as "chart" | "table") || "chart"
to: `/reports/trends${preserveSearch}`, );
icon: <TrendingUp size={24} />,
title: t("reports.hub.trends"), const toggleHidden = useCallback((name: string) => {
description: t("reports.hub.trendsDescription"), setHiddenCategories((prev) => {
}, const next = new Set(prev);
{ if (next.has(name)) next.delete(name);
to: `/reports/compare${preserveSearch}`, else next.add(name);
icon: <Scale size={24} />, return next;
title: t("reports.hub.compare"), });
description: t("reports.hub.compareDescription"), }, []);
},
{ const showAll = useCallback(() => setHiddenCategories(new Set()), []);
to: `/reports/category${preserveSearch}`,
icon: <Search size={24} />, const viewDetails = useCallback((item: CategoryBreakdownItem) => {
title: t("reports.hub.categoryZoom"), setDetailModal(item);
description: t("reports.hub.categoryZoomDescription"), }, []);
},
{ const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo);
to: `/reports/cartes${preserveSearch}`,
icon: <LayoutDashboard size={24} />, const filterCategories = useMemo(() => {
title: t("reports.hub.cartes"), if (state.tab === "byCategory") {
description: t("reports.hub.cartesDescription"), return state.categorySpending.map((c) => ({ name: c.category_name, color: c.category_color }));
}, }
]; if (state.tab === "overTime") {
return state.categoryOverTime.categories.map((name) => ({
name,
color: state.categoryOverTime.colors[name] || "#9ca3af",
}));
}
return [];
}, [state.tab, state.categorySpending, state.categoryOverTime]);
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
return ( return (
<div> <div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6"> <div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("reports.hub.title")}</h1> {state.tab === "budgetVsActual" ? (
<h1 className="text-2xl font-bold flex items-center gap-2 flex-wrap">
{t("reports.bva.titlePrefix")}
<select
value={`${state.budgetYear}-${state.budgetMonth}`}
onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number);
setBudgetMonth(y, m);
}}
className="text-lg font-bold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
>
{monthOptions.map((opt) => (
<option key={opt.key} value={opt.value}>
{opt.label}
</option>
))}
</select>
</h1>
) : (
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
)}
<PageHelp helpKey="reports" /> <PageHelp helpKey="reports" />
</div> </div>
{state.tab !== "budgetVsActual" && (
<PeriodSelector <PeriodSelector
value={period} value={state.period}
onChange={setPeriod} onChange={setPeriod}
customDateFrom={from} customDateFrom={state.customDateFrom}
customDateTo={to} customDateTo={state.customDateTo}
onCustomDateChange={setCustomDates} onCustomDateChange={setCustomDates}
/> />
)}
</div> </div>
<HubHighlightsPanel data={data} isLoading={isLoading} error={error} /> <div className="flex gap-2 mb-6 flex-wrap items-center">
{TABS.map((tab) => (
<section> <button
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3"> key={tab}
{t("reports.hub.explore")} onClick={() => setTab(tab)}
</h2> className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3"> tab === state.tab
{navCards.map((card) => ( ? "bg-[var(--primary)] text-white"
<HubReportNavCard key={card.to} {...card} /> : "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(`reports.${tab}`)}
</button>
))} ))}
{["trends", "byCategory", "overTime"].includes(state.tab) && (
<>
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
{([
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
]).map(({ mode, icon, label }) => (
<button
key={mode}
onClick={() => {
setViewMode(mode);
localStorage.setItem("reports-view-mode", mode);
}}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
mode === viewMode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
{viewMode === "chart" && (
<>
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
<button
onClick={() => {
setShowAmounts((prev) => {
const next = !prev;
localStorage.setItem("reports-show-amounts", String(next));
return next;
});
}}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
showAmounts
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
title={showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
>
<Hash size={14} />
{showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
</button>
</>
)}
</>
)}
</div> </div>
</section>
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}
</div>
)}
<div className={showFilterPanel ? "flex gap-4 items-start" : ""}>
<div className={showFilterPanel ? "flex-1 min-w-0" : ""}>
{state.tab === "trends" && (
viewMode === "chart" ? (
<MonthlyTrendsChart data={state.monthlyTrends} showAmounts={showAmounts} />
) : (
<MonthlyTrendsTable data={state.monthlyTrends} />
)
)}
{state.tab === "byCategory" && (
viewMode === "chart" ? (
<CategoryBarChart
data={state.categorySpending}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
showAmounts={showAmounts}
/>
) : (
<CategoryTable data={state.categorySpending} hiddenCategories={hiddenCategories} />
)
)}
{state.tab === "overTime" && (
viewMode === "chart" ? (
<CategoryOverTimeChart
data={state.categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
showAmounts={showAmounts}
/>
) : (
<CategoryOverTimeTable data={state.categoryOverTime} hiddenCategories={hiddenCategories} />
)
)}
{state.tab === "budgetVsActual" && (
<BudgetVsActualTable data={state.budgetVsActual} />
)}
{state.tab === "dynamic" && (
<DynamicReport
config={state.pivotConfig}
result={state.pivotResult}
onConfigChange={setPivotConfig}
/>
)}
</div>
{showFilterPanel && (
<ReportFilterPanel
categories={filterCategories}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
sources={sources}
selectedSourceId={state.sourceId}
onSourceChange={setSourceId}
categoryType={state.tab === "overTime" ? state.categoryType : undefined}
onCategoryTypeChange={state.tab === "overTime" ? setCategoryType : undefined}
/>
)}
</div>
{detailModal && (
<TransactionDetailModal
categoryId={detailModal.category_id}
categoryName={detailModal.category_name}
categoryColor={detailModal.category_color}
dateFrom={dateFrom}
dateTo={dateTo}
onClose={() => setDetailModal(null)}
/>
)}
</div> </div>
); );
} }

View file

@ -1,116 +0,0 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
import { useTrends } from "../hooks/useTrends";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import type { CategoryBreakdownItem } from "../shared/types";
const STORAGE_KEY = "reports-viewmode-trends";
export default function ReportsTrendsPage() {
const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const toggleHidden = useCallback((name: string) => {
setHiddenCategories((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
// viewDetails not used in trends view — transactions details are accessed from category zoom.
const noOpDetails = useCallback((_item: CategoryBreakdownItem) => {}, []);
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<div className="flex gap-2 items-center flex-wrap">
<div className="inline-flex gap-1">
<button
type="button"
onClick={() => setSubView("global")}
aria-pressed={subView === "global"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "global"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t("reports.trends.subviewGlobal")}
</button>
<button
type="button"
onClick={() => setSubView("byCategory")}
aria-pressed={subView === "byCategory"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "byCategory"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t("reports.trends.subviewByCategory")}
</button>
</div>
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div>
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{subView === "global" ? (
viewMode === "chart" ? (
<MonthlyTrendsChart data={monthlyTrends} />
) : (
<MonthlyTrendsTable data={monthlyTrends} />
)
) : viewMode === "chart" ? (
<CategoryOverTimeChart
data={categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={noOpDetails}
/>
) : (
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
)}
</div>
);
}

View file

@ -19,10 +19,7 @@ import { Link } from "react-router-dom";
import { APP_NAME } from "../shared/constants"; import { APP_NAME } from "../shared/constants";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import DataManagementCard from "../components/settings/DataManagementCard"; import DataManagementCard from "../components/settings/DataManagementCard";
import LicenseCard from "../components/settings/LicenseCard";
import AccountCard from "../components/settings/AccountCard";
import LogViewerCard from "../components/settings/LogViewerCard"; import LogViewerCard from "../components/settings/LogViewerCard";
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
export default function SettingsPage() { export default function SettingsPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -75,16 +72,6 @@ export default function SettingsPage() {
<PageHelp helpKey="settings" /> <PageHelp helpKey="settings" />
</div> </div>
{/* License card */}
<LicenseCard />
{/* Account card */}
<AccountCard />
{/* Security banner renders only when OAuth tokens are in the
file fallback instead of the OS keychain */}
<TokenStoreFallbackBanner />
{/* About card */} {/* About card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View file

@ -1,28 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wand2, Tag } from "lucide-react"; import { Wand2 } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import { useTransactions } from "../hooks/useTransactions"; import { useTransactions } from "../hooks/useTransactions";
import TransactionFilterBar from "../components/transactions/TransactionFilterBar"; import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar"; import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
import TransactionTable from "../components/transactions/TransactionTable"; import TransactionTable from "../components/transactions/TransactionTable";
import TransactionPagination from "../components/transactions/TransactionPagination"; import TransactionPagination from "../components/transactions/TransactionPagination";
import ContextMenu from "../components/shared/ContextMenu";
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import type { TransactionRow } from "../shared/types";
export default function TransactionsPage() { export default function TransactionsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } = const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
useTransactions(); useTransactions();
const [resultMessage, setResultMessage] = useState<string | null>(null); const [resultMessage, setResultMessage] = useState<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
const [pending, setPending] = useState<TransactionRow | null>(null);
const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, row });
};
const handleAutoCategorize = async () => { const handleAutoCategorize = async () => {
setResultMessage(null); setResultMessage(null);
@ -94,7 +84,6 @@ export default function TransactionsPage() {
onLoadSplitChildren={loadSplitChildren} onLoadSplitChildren={loadSplitChildren}
onSaveSplit={saveSplit} onSaveSplit={saveSplit}
onDeleteSplit={deleteSplit} onDeleteSplit={deleteSplit}
onRowContextMenu={handleRowContextMenu}
/> />
<TransactionPagination <TransactionPagination
@ -105,31 +94,6 @@ export default function TransactionsPage() {
/> />
</> </>
)} )}
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
header={menu.row.description}
onClose={() => setMenu(null)}
items={[
{
icon: <Tag size={14} />,
label: t("reports.keyword.addFromTransaction"),
onClick: () => setPending(menu.row),
},
]}
/>
)}
{pending && (
<AddKeywordDialog
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
initialCategoryId={pending.category_id}
onClose={() => setPending(null)}
onApplied={() => setPending(null)}
/>
)}
</div> </div>
); );
} }

View file

@ -1,34 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
export interface AccountInfo {
email: string;
name: string | null;
picture: string | null;
subscription_status: string | null;
}
export async function startOAuth(): Promise<string> {
return invoke<string>("start_oauth");
}
export async function refreshAuthToken(): Promise<AccountInfo> {
return invoke<AccountInfo>("refresh_auth_token");
}
export async function getAccountInfo(): Promise<AccountInfo | null> {
return invoke<AccountInfo | null>("get_account_info");
}
export async function checkSubscriptionStatus(): Promise<AccountInfo | null> {
return invoke<AccountInfo | null>("check_subscription_status");
}
export async function logoutAccount(): Promise<void> {
return invoke<void>("logout");
}
export type TokenStoreMode = "keychain" | "file";
export async function getTokenStoreMode(): Promise<TokenStoreMode | null> {
return invoke<TokenStoreMode | null>("get_token_store_mode");
}

View file

@ -1,174 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
validateKeyword,
previewKeywordMatches,
applyKeywordWithReassignment,
KEYWORD_MAX_LENGTH,
} from "./categorizationService";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
mockExecute.mockReset();
});
describe("validateKeyword", () => {
it("rejects whitespace-only input", () => {
expect(validateKeyword(" ")).toEqual({ ok: false, reason: "tooShort" });
});
it("rejects single-character keywords", () => {
expect(validateKeyword("a")).toEqual({ ok: false, reason: "tooShort" });
});
it("accepts a minimal two-character keyword after trim", () => {
expect(validateKeyword(" ab ")).toEqual({ ok: true, value: "ab" });
});
it("rejects keywords longer than 64 characters (ReDoS cap)", () => {
const long = "a".repeat(KEYWORD_MAX_LENGTH + 1);
expect(validateKeyword(long)).toEqual({ ok: false, reason: "tooLong" });
});
it("accepts keywords at exactly 64 characters", () => {
const borderline = "a".repeat(KEYWORD_MAX_LENGTH);
expect(validateKeyword(borderline)).toEqual({ ok: true, value: borderline });
});
});
describe("previewKeywordMatches", () => {
it("binds the LIKE pattern as a parameter (no interpolation)", async () => {
mockSelect.mockResolvedValueOnce([]);
await previewKeywordMatches("METRO");
expect(mockSelect).toHaveBeenCalledTimes(1);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("LIKE $1");
expect(sql).not.toContain("'metro'");
expect(sql).not.toContain("'%metro%'");
expect(params[0]).toBe("%metro%");
});
it("returns an empty preview when the keyword is invalid", async () => {
const result = await previewKeywordMatches("a");
expect(result).toEqual({ visible: [], totalMatches: 0 });
expect(mockSelect).not.toHaveBeenCalled();
});
it("filters candidates with the regex and respects the visible cap", async () => {
// 3 rows: 2 true matches, 1 substring-only (should be dropped by word-boundary)
mockSelect.mockResolvedValueOnce([
{ id: 1, date: "2026-03-15", description: "METRO #123", amount: -45, category_name: null, category_color: null },
{ id: 2, date: "2026-03-02", description: "METRO PLUS", amount: -67.2, category_name: null, category_color: null },
{ id: 3, date: "2026-02-18", description: "METROPOLITAIN", amount: -12, category_name: null, category_color: null },
]);
const result = await previewKeywordMatches("METRO", 2);
expect(result.totalMatches).toBe(2);
expect(result.visible).toHaveLength(2);
expect(result.visible[0].id).toBe(1);
expect(result.visible[1].id).toBe(2);
});
});
describe("applyKeywordWithReassignment", () => {
it("wraps INSERT + UPDATEs in a BEGIN/COMMIT transaction", async () => {
mockSelect.mockResolvedValueOnce([]); // existing keyword lookup → none
mockExecute.mockResolvedValue({ lastInsertId: 77, rowsAffected: 1 });
await applyKeywordWithReassignment({
keyword: "METRO",
categoryId: 3,
transactionIds: [1, 2],
allowReplaceExisting: false,
});
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
expect(commands[0]).toBe("BEGIN");
expect(commands[commands.length - 1]).toBe("COMMIT");
expect(commands.filter((c) => c.startsWith("INSERT INTO keywords"))).toHaveLength(1);
expect(commands.filter((c) => c.startsWith("UPDATE transactions"))).toHaveLength(2);
});
it("rolls back when an UPDATE throws", async () => {
mockSelect.mockResolvedValueOnce([]);
mockExecute
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ lastInsertId: 77 }) // INSERT keywords
.mockRejectedValueOnce(new Error("disk full")); // UPDATE transactions fails
await expect(
applyKeywordWithReassignment({
keyword: "METRO",
categoryId: 3,
transactionIds: [1],
allowReplaceExisting: false,
}),
).rejects.toThrow("disk full");
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
expect(commands).toContain("BEGIN");
expect(commands).toContain("ROLLBACK");
expect(commands).not.toContain("COMMIT");
});
it("blocks reassignment when keyword exists for another category without allowReplaceExisting", async () => {
mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]);
await expect(
applyKeywordWithReassignment({
keyword: "METRO",
categoryId: 3,
transactionIds: [1],
allowReplaceExisting: false,
}),
).rejects.toThrow("keyword_already_exists");
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
expect(commands).toContain("BEGIN");
expect(commands).toContain("ROLLBACK");
});
it("reassigns existing keyword when allowReplaceExisting is true", async () => {
mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]);
mockExecute.mockResolvedValue({ rowsAffected: 1 });
const result = await applyKeywordWithReassignment({
keyword: "METRO",
categoryId: 3,
transactionIds: [1, 2],
allowReplaceExisting: true,
});
expect(result.replacedExisting).toBe(true);
expect(result.updatedTransactions).toBe(2);
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
expect(commands.some((c) => c.startsWith("UPDATE keywords SET category_id"))).toBe(true);
expect(commands).toContain("COMMIT");
});
it("rejects invalid keywords before touching the database", async () => {
await expect(
applyKeywordWithReassignment({
keyword: "a",
categoryId: 3,
transactionIds: [1],
allowReplaceExisting: false,
}),
).rejects.toThrow("invalid_keyword:tooShort");
expect(mockExecute).not.toHaveBeenCalled();
});
});

View file

@ -1,5 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import type { Keyword, RecentTransaction } from "../shared/types"; import type { Keyword } from "../shared/types";
/** /**
* Normalize a description for keyword matching: * Normalize a description for keyword matching:
@ -7,7 +7,7 @@ import type { Keyword, RecentTransaction } from "../shared/types";
* - strip accents via NFD decomposition * - strip accents via NFD decomposition
* - collapse whitespace * - collapse whitespace
*/ */
export function normalizeDescription(desc: string): string { function normalizeDescription(desc: string): string {
return desc return desc
.normalize("NFD") .normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") .replace(/[\u0300-\u036f]/g, "")
@ -25,7 +25,7 @@ const WORD_CHAR = /\w/;
* (e.g., brackets, parentheses, dashes). This ensures keywords like * (e.g., brackets, parentheses, dashes). This ensures keywords like
* "[VIREMENT]" or "(INTERAC)" can match correctly. * "[VIREMENT]" or "(INTERAC)" can match correctly.
*/ */
export function buildKeywordRegex(normalizedKeyword: string): RegExp { function buildKeywordRegex(normalizedKeyword: string): RegExp {
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const left = WORD_CHAR.test(normalizedKeyword[0]) const left = WORD_CHAR.test(normalizedKeyword[0])
? "\\b" ? "\\b"
@ -50,7 +50,7 @@ interface CompiledKeyword {
/** /**
* Compile keywords into regex patterns once for reuse across multiple matches. * Compile keywords into regex patterns once for reuse across multiple matches.
*/ */
export function compileKeywords(keywords: Keyword[]): CompiledKeyword[] { function compileKeywords(keywords: Keyword[]): CompiledKeyword[] {
return keywords.map((kw) => ({ return keywords.map((kw) => ({
regex: buildKeywordRegex(normalizeDescription(kw.keyword)), regex: buildKeywordRegex(normalizeDescription(kw.keyword)),
category_id: kw.category_id, category_id: kw.category_id,
@ -112,162 +112,3 @@ export async function categorizeBatch(
return matchDescription(normalized, compiled); return matchDescription(normalized, compiled);
}); });
} }
// --- AddKeywordDialog support (Issue #74) ---
export const KEYWORD_MIN_LENGTH = 2;
export const KEYWORD_MAX_LENGTH = 64;
export const KEYWORD_PREVIEW_LIMIT = 50;
/**
* Validate a keyword before it hits the regex engine.
*
* Rejects whitespace-only input and caps length at 64 chars to prevent
* ReDoS (CWE-1333) when the compiled regex is replayed across many
* transactions later.
*/
export function validateKeyword(raw: string): { ok: true; value: string } | { ok: false; reason: "tooShort" | "tooLong" } {
const trimmed = raw.trim();
if (trimmed.length < KEYWORD_MIN_LENGTH) return { ok: false, reason: "tooShort" };
if (trimmed.length > KEYWORD_MAX_LENGTH) return { ok: false, reason: "tooLong" };
return { ok: true, value: trimmed };
}
/**
* Preview the transactions that would be recategorised if the user commits
* the given keyword. Uses a parameterised `LIKE ?1` to scope the candidates,
* then re-filters in memory with `buildKeywordRegex` for exact word-boundary
* matching. Results are capped at `limit` visible rows callers decide what
* to do with the `totalMatches` (which may be greater than the returned list).
*
* SECURITY: the keyword is never interpolated into the SQL string. `LIKE ?1`
* is the only parameterised binding, and the `%...%` wrapping happens inside
* the bound parameter value.
*/
export async function previewKeywordMatches(
keyword: string,
limit: number = KEYWORD_PREVIEW_LIMIT,
): Promise<{ visible: RecentTransaction[]; totalMatches: number }> {
const validation = validateKeyword(keyword);
if (!validation.ok) {
return { visible: [], totalMatches: 0 };
}
const normalized = normalizeDescription(validation.value);
const regex = buildKeywordRegex(normalized);
const db = await getDb();
// Coarse pre-filter via parameterised LIKE (case-insensitive thanks to
// normalize on the JS side). A small cap protects against catastrophic
// backtracking across a huge candidate set — hard-capped to 1000 rows
// before the in-memory filter.
const likePattern = `%${normalized}%`;
const candidates = await db.select<RecentTransaction[]>(
`SELECT t.id, t.date, t.description, t.amount,
c.name AS category_name,
c.color AS category_color
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE LOWER(t.description) LIKE $1
ORDER BY t.date DESC
LIMIT 1000`,
[likePattern],
);
const matched: RecentTransaction[] = [];
for (const tx of candidates) {
const normDesc = normalizeDescription(tx.description);
if (regex.test(normDesc)) matched.push(tx);
}
return {
visible: matched.slice(0, limit),
totalMatches: matched.length,
};
}
export interface ApplyKeywordInput {
keyword: string;
categoryId: number;
/** ids of transactions to recategorise (only those the user checked). */
transactionIds: number[];
/**
* When true, and a keyword with the same spelling already exists for a
* different category, that existing keyword is **reassigned** to the new
* category rather than creating a duplicate. Matches the spec decision
* that history is never touched only the visible transactions are
* recategorised.
*/
allowReplaceExisting: boolean;
}
export interface ApplyKeywordResult {
keywordId: number;
updatedTransactions: number;
replacedExisting: boolean;
}
/**
* INSERTs (or reassigns) a keyword and recategorises the given transaction
* ids in a single SQL transaction. Either all writes commit or none do.
*
* SECURITY: every query is parameterised. The caller is expected to have
* vetted `transactionIds` from a preview window that the user confirmed.
*/
export async function applyKeywordWithReassignment(
input: ApplyKeywordInput,
): Promise<ApplyKeywordResult> {
const validation = validateKeyword(input.keyword);
if (!validation.ok) {
throw new Error(`invalid_keyword:${validation.reason}`);
}
const keyword = validation.value;
const db = await getDb();
await db.execute("BEGIN");
try {
// Is there already a row for this keyword spelling?
const existing = await db.select<Array<{ id: number; category_id: number }>>(
`SELECT id, category_id FROM keywords WHERE keyword = $1 LIMIT 1`,
[keyword],
);
let keywordId: number;
let replacedExisting = false;
if (existing.length > 0) {
if (!input.allowReplaceExisting && existing[0].category_id !== input.categoryId) {
throw new Error("keyword_already_exists");
}
await db.execute(
`UPDATE keywords SET category_id = $1, is_active = 1 WHERE id = $2`,
[input.categoryId, existing[0].id],
);
keywordId = existing[0].id;
replacedExisting = existing[0].category_id !== input.categoryId;
} else {
const result = await db.execute(
`INSERT INTO keywords (keyword, category_id, priority) VALUES ($1, $2, $3)`,
[keyword, input.categoryId, 100],
);
keywordId = Number(result.lastInsertId ?? 0);
}
let updatedTransactions = 0;
for (const txId of input.transactionIds) {
await db.execute(
`UPDATE transactions
SET category_id = $1,
is_manually_categorized = 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[input.categoryId, txId],
);
updatedTransactions++;
}
await db.execute("COMMIT");
return { keywordId, updatedTransactions, replacedExisting };
} catch (e) {
await db.execute("ROLLBACK");
throw e;
}
}

View file

@ -1,20 +0,0 @@
import { describe, it, expect } from "vitest";
import { isFeedbackErrorCode } from "./feedbackService";
describe("isFeedbackErrorCode", () => {
it("recognizes the four stable error codes", () => {
expect(isFeedbackErrorCode("invalid")).toBe(true);
expect(isFeedbackErrorCode("rate_limit")).toBe(true);
expect(isFeedbackErrorCode("server_error")).toBe(true);
expect(isFeedbackErrorCode("network_error")).toBe(true);
});
it("rejects unknown strings and non-strings", () => {
expect(isFeedbackErrorCode("boom")).toBe(false);
expect(isFeedbackErrorCode("")).toBe(false);
expect(isFeedbackErrorCode(404)).toBe(false);
expect(isFeedbackErrorCode(null)).toBe(false);
expect(isFeedbackErrorCode(undefined)).toBe(false);
expect(isFeedbackErrorCode({ error: "rate_limit" })).toBe(false);
});
});

View file

@ -1,50 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
export type FeedbackErrorCode =
| "invalid"
| "rate_limit"
| "server_error"
| "network_error";
export interface FeedbackContext {
page?: string;
locale?: string;
theme?: string;
viewport?: string;
userAgent?: string;
timestamp?: string;
}
export interface FeedbackSuccess {
id: string;
created_at: string;
}
export interface SendFeedbackInput {
content: string;
userId?: string | null;
context?: FeedbackContext;
}
export async function sendFeedback(
input: SendFeedbackInput,
): Promise<FeedbackSuccess> {
return invoke<FeedbackSuccess>("send_feedback", {
content: input.content,
userId: input.userId ?? null,
context: input.context ?? null,
});
}
export async function getFeedbackUserAgent(): Promise<string> {
return invoke<string>("get_feedback_user_agent");
}
export function isFeedbackErrorCode(value: unknown): value is FeedbackErrorCode {
return (
value === "invalid" ||
value === "rate_limit" ||
value === "server_error" ||
value === "network_error"
);
}

View file

@ -1,64 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
export type Edition = "free" | "base" | "premium";
export interface LicenseInfo {
edition: Edition;
email: string;
features: string[];
machine_limit: number;
issued_at: number;
expires_at: number;
}
export async function validateLicenseKey(key: string): Promise<LicenseInfo> {
return invoke<LicenseInfo>("validate_license_key", { key });
}
export async function storeLicense(key: string): Promise<LicenseInfo> {
return invoke<LicenseInfo>("store_license", { key });
}
export async function readLicense(): Promise<LicenseInfo | null> {
return invoke<LicenseInfo | null>("read_license");
}
export async function getEdition(): Promise<Edition> {
return invoke<Edition>("get_edition");
}
export async function getMachineId(): Promise<string> {
return invoke<string>("get_machine_id");
}
export async function checkEntitlement(feature: string): Promise<boolean> {
return invoke<boolean>("check_entitlement", { feature });
}
export interface MachineInfo {
machine_id: string;
machine_name: string | null;
activated_at: string;
last_seen_at: string;
}
export interface ActivationStatus {
is_activated: boolean;
machine_id: string;
}
export async function activateMachine(): Promise<void> {
return invoke<void>("activate_machine");
}
export async function deactivateMachine(machineId: string): Promise<void> {
return invoke<void>("deactivate_machine", { machineId });
}
export async function listActivatedMachines(): Promise<MachineInfo[]> {
return invoke<MachineInfo[]>("list_activated_machines");
}
export async function getActivationStatus(): Promise<ActivationStatus> {
return invoke<ActivationStatus>("get_activation_status");
}

Some files were not shown because too many files have changed in this diff Show more