Compare commits

..

No commits in common. "main" and "v0.5.0" have entirely different histories.
main ... v0.5.0

43 changed files with 704 additions and 2439 deletions

View file

@ -67,11 +67,6 @@ jobs:
cp src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig release-assets/ 2>/dev/null || true cp src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig release-assets/ 2>/dev/null || true
ls -la release-assets/ ls -la release-assets/
- name: Copy changelogs to public
run: |
cp CHANGELOG.md public/CHANGELOG.md
cp CHANGELOG.fr.md public/CHANGELOG.fr.md
- name: Extract changelog - name: Extract changelog
id: changelog id: changelog
run: | run: |
@ -212,35 +207,27 @@ jobs:
- name: Publish latest.json to package registry - name: Publish latest.json to package registry
env: env:
FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }} FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
# DELETE uses API v1, PUT uses the package upload API PKG_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
DELETE_URL="${GITHUB_SERVER_URL}/api/v1/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
UPLOAD_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
# Delete the old package version to avoid 409 conflicts # Delete the old package version to avoid 409 conflicts
echo "Deleting old package version (if any)..." echo "Deleting old package version (if any)..."
DEL_CODE=$(curl -s -w "%{http_code}" -X DELETE \ DEL_CODE=$(curl -s -w "%{http_code}" -X DELETE \
"${DELETE_URL}" \ "${PKG_URL}" \
-H "Authorization: token ${FORGEJO_TOKEN}" \ -H "Authorization: token ${FORGEJO_TOKEN}" \
-o /tmp/del_response.json) -o /tmp/del_response.json)
echo "Delete HTTP $DEL_CODE" echo "Delete HTTP $DEL_CODE"
# 204 = deleted, 404 = didn't exist (both OK)
if [ "$DEL_CODE" != "204" ] && [ "$DEL_CODE" != "404" ]; then
echo "WARNING: Unexpected delete response:"
cat /tmp/del_response.json
fi
echo "Uploading latest.json to package registry..." echo "Uploading latest.json to package registry..."
HTTP_CODE=$(curl -w "%{http_code}" -X PUT \ HTTP_CODE=$(curl -w "%{http_code}" -X PUT \
"${UPLOAD_URL}/latest.json" \ "${PKG_URL}/latest.json" \
-H "Authorization: token ${FORGEJO_TOKEN}" \ -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data-binary "@release-assets/latest.json" \ --data-binary "@release-assets/latest.json" \
-o /tmp/pkg_response.json) -o /tmp/pkg_response.json)
echo "Upload HTTP $HTTP_CODE" echo "Upload HTTP $HTTP_CODE"
if [ "$HTTP_CODE" != "201" ]; then if [ "$HTTP_CODE" != "201" ]; then
echo "ERROR: Failed to publish latest.json:" echo "Upload response:"
cat /tmp/pkg_response.json cat /tmp/pkg_response.json
exit 1
fi fi

9
.gitignore vendored
View file

@ -23,13 +23,8 @@ imports/*.csv
# Environment # Environment
.env .env
.env.local .env.local
.env.*
*.local *.local
# Secrets
*.pem
*.key
# IDE # IDE
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@ -45,9 +40,5 @@ imports/*.csv
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Auto-generated changelogs (synced from root by vite.config.ts)
public/CHANGELOG.md
public/CHANGELOG.fr.md
# Tauri generated # Tauri generated
src-tauri/gen/ src-tauri/gen/

View file

@ -1,266 +0,0 @@
# Journal des modifications
## [Non publié]
## [0.6.6]
### Modifié
- Tableau de budget : la colonne année précédente affiche maintenant le réel (transactions) au lieu du budget planifié (#34)
- Refactorisation de `buildPrevYearTotalMap` en inline et simplification des tests (#39)
### Corrigé
- Fichiers changelog synchronisés automatiquement via plugin Vite, copies obsolètes dans public/ supprimées (#37)
## [0.6.5]
### Ajouté
- Tableau de bord : menu déroulant de sélection du mois pour la section Budget vs Réel avec le dernier mois complété par défaut (#31)
### Modifié
- Rapports et tableau de bord : police réduite dans le menu déroulant de mois pour un meilleur équilibre visuel (#31)
## [0.6.4]
### Ajouté
- Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16)
### Corrigé
- Tableau de bord : les catégories de niveau 4+ apparaissent maintenant sous leur parent au lieu du bas de la section (#23)
- Tableau de bord : la hiérarchie de catégories supporte maintenant une profondeur de niveaux arbitraire (#23)
### Modifié
- Tableau de bord : le graphique circulaire prend 1/3 de la largeur au lieu de 1/2, donnant plus d'espace au tableau budget (#23)
- Tableau de bord : les étiquettes du graphique circulaire s'affichent uniquement au survol via le tooltip (#23)
- Budget vs Réel : la colonne des catégories reste désormais fixe lors du défilement horizontal (#29)
- Budget vs Réel : titre changé pour « Budget vs Réel pour le mois de [mois] » avec un menu déroulant pour sélectionner le mois (#29)
- Budget vs Réel : le mois par défaut est maintenant le dernier mois complété au lieu du mois courant (#29)
## [0.6.3]
### Ajouté
- Tableau de bord : histogramme empilé des dépenses par catégorie et par mois (#15)
- Tableau de bord : tableau budget vs réel du mois courant avec écart en $ et % (#15)
- Tableau de budget et rapport Budget vs Réel : formatage des sous-totaux de section avec poids visuel croissant (#14)
### Modifié
- Tableau de bord : période par défaut changée de « mois » à « année à ce jour » (#15)
- Tableau de bord : section des transactions récentes supprimée (#15)
- Tous les rapports tabulaires : les lignes de grand total utilisent maintenant une police plus grande (text-sm), gras et bordure supérieure plus épaisse pour une meilleure hiérarchie visuelle (#14)
### Corrigé
- Rapport catégories dans le temps : toutes les catégories sont maintenant affichées (limite passée de 8 à 50) (#13)
- Graphique par catégorie : les noms sur l'axe Y utilisent maintenant la couleur du texte principal au lieu du gris (#13)
- Graphique catégories dans le temps : le texte de la légende utilise maintenant la couleur du texte principal au lieu d'hériter la couleur de la catégorie (#13)
## [0.6.2]
### Ajouté
- Tableau de budget : sous-totaux par section (dépenses, revenus, transferts) affichés après chaque groupe (#11)
- Rapport Budget vs Réel : sous-totaux par section avec réel, prévu, écart ($) et écart (%) par type (#11)
### Corrigé
- Page catégories : le panneau de détail reste maintenant visible lors du défilement d'une longue liste de catégories (#12)
## [0.6.1]
### Ajouté
- Page historique des versions : historique complet accessible depuis les Paramètres à tout moment
- Changelog bilingue : les notes de version s'affichent dans la langue choisie par l'utilisateur (FR/EN)
### Corrigé
- Visibilité des labels de graphiques : les montants sur les barres empilées utilisent maintenant du texte noir avec contour blanc pour un meilleur contraste (#8)
- Tableau de budget : les cellules éditables affichent maintenant un fond au survol, un curseur pointeur et une info-bulle pour clarifier l'interaction (#9)
## [0.6.0]
### Ajouté
- Rapports : bascule entre vue tableau et graphique pour les onglets Tendances, Par catégorie et Évolution
- Rapports : option « Afficher les montants » pour afficher les valeurs directement sur les barres et courbes
- Rapports : panneau de filtres avec cases à cocher par catégorie (recherche, tout sélectionner/désélectionner) et filtre par source
- Rapports : le filtre source s'applique au niveau SQL pour des totaux filtrés précis
- Rapports : en-têtes de tableau fixes sur tous les tableaux de rapports (Rapport dynamique, Budget vs Réel)
- Rapports : survol interactif — barres non survolées estompées, info-bulle filtrée sur la catégorie survolée
- Rapports : le survol de la légende met en évidence la catégorie sur tous les mois (graphique Évolution)
### Corrigé
- Tableau des transactions : l'icône de commentaire devient orange (comme l'icône de ventilation) quand une note est présente (#7)
## [0.5.0]
### Ajouté
- Gestion d'erreurs : intercepte les plantages React et affiche une page d'erreur au lieu d'un écran blanc
- Délai de démarrage (10 s) sur la connexion à la base de données — affiche une page d'erreur au lieu d'un indicateur de chargement infini
- Page d'erreur avec « Rafraîchir », « Vérifier les mises à jour » et liens de contact
- Visionneuse de journaux dans les paramètres — capture la sortie console, filtrable par niveau, copiable, persiste entre les rafraîchissements
- Licence GPL-3.0 — le projet est maintenant open source
### Modifié
- Modale détail de rapport : colonnes triables — cliquez sur les en-têtes pour trier par date, description ou montant (#1)
- Modale détail de rapport : bascule pour afficher/masquer la colonne des montants (#3)
- Tableau de budget : les en-têtes de colonnes restent fixes lors du défilement vertical (#2)
### Corrigé
- Mise à jour automatique sur Linux : le champ version de `latest.json` n'a plus le préfixe `v`, téléversement au registre de paquets plus robuste
- Nouvelle tentative au démarrage : la connexion BD réessaie jusqu'à 3 fois avant d'afficher la page d'erreur (corrige l'échec au premier lancement sur Windows)
- Somme de contrôle de migration : répare automatiquement la somme de contrôle obsolète de la migration 1 au démarrage
## [0.4.4]
### Corrigé
- Le binaire Linux est maintenant compatible avec glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — le CI compile dans un conteneur Ubuntu 22.04
## [0.4.3]
### Corrigé
- Le point de terminaison de mise à jour automatique utilise maintenant le registre de paquets Forgejo pour une URL stable
- Les signatures Linux (.AppImage.sig) sont maintenant correctement collectées dans le CI
- Toutes les signatures de plateforme (.deb.sig, .rpm.sig) sont maintenant incluses dans les assets de la release
## [0.4.2]
### Modifié
- La mise à jour automatique pointe maintenant vers l'instance Forgejo auto-hébergée
- Les builds Windows sont maintenant compilés en croisé via cargo-xwin
## [0.4.1]
### Corrigé
- Application bloquée sur un indicateur de chargement infini après mise à jour depuis v0.3.x (somme de contrôle de migration incompatible sur seed_categories.sql)
- Les erreurs de connexion BD sont maintenant journalisées dans la console au lieu d'échouer silencieusement
## [0.4.0]
### Ajouté
- Catégories : support de 3 niveaux de hiérarchie (ex : Dépenses récurrentes → Assurances → Assurance-auto)
- Rapport dynamique : nouveau champ « Catégorie (Niveau 3) »
- Budget : sous-totaux intermédiaires et indentation 3 niveaux pour les catégories imbriquées
- Catégories : gestion automatique de `is_inputable` à la création/suppression de sous-catégories
- Catégories : la validation de profondeur empêche la création d'un 4e niveau
- Données initiales : Assurances divisées en Assurance-auto, Assurance-habitation, Assurance-vie
### Corrigé
- Auto-catégorisation : les mots-clés commençant/finissant par des caractères spéciaux (`[`, `]`, `(`, `)`, `-`, etc.) sont maintenant reconnus
- Auto-catégorisation : pré-compilation des regex pour de meilleures performances en lot
## [0.3.11]
### Ajouté
- Rapport dynamique : support de plusieurs dimensions en colonnes (clés composites)
### Corrigé
- Rapport dynamique : n'est plus affecté par les filtres de date globaux — utilise uniquement ses propres filtres du panneau
## [0.3.10]
### Ajouté
- Rapport dynamique : les champs peuvent maintenant être utilisés dans plusieurs zones simultanément (lignes + filtres, colonnes + filtres)
- Rapport dynamique : clic-droit sur une valeur de filtre pour l'exclure (affiché barré en rouge)
- Option de période « Cette année » dans les rapports et le tableau de bord (du 1er janvier à aujourd'hui)
## [0.3.9]
### Ajouté
- Rapport dynamique (tableau croisé) : composez des rapports personnalisés en assignant des dimensions aux lignes, colonnes, filtres et mesures aux valeurs
- Suppression de mots-clés depuis la vue « Tous les mots-clés »
## [0.3.8]
### Ajouté
- Sélecteur de plage de dates personnalisée pour les rapports et le tableau de bord
- Bascule pour positionner les sous-totaux au-dessus ou en dessous des lignes de détail
- Affichage des notes de version du CHANGELOG dans les releases et le système de mise à jour
## [0.3.7]
### Corrigé
- Suppression du bundle MSI pour éviter le conflit de chemin d'installation du système de mise à jour
- Changement du mode d'installation Windows à basicUi
- Amélioration de la visibilité de l'indicateur de ventilation et de la mise en page des ajustements
## 0.3.2
### Nouvelles fonctionnalités
- **Support Linux** : ajout des builds Linux (`.deb`, `.rpm`, `.AppImage`) au workflow de release
- **Ventilations sur la page Ajustements** : visualisez les ajustements de ventilation des transactions dans une section dédiée
### Corrigé
- Correction de cas limites de détection automatique CSV
- Suppression de l'accent dans productName pour la compatibilité `.deb` Linux
## 0.3.1
### Corrigé
- Toujours afficher le sélecteur de profil dans la barre latérale (#2)
## 0.3.0
### Nouvelles fonctionnalités
- **Profils multiples** : créez plusieurs profils avec des bases de données séparées, des noms et couleurs personnalisés
- **Protection par NIP** : protégez les profils avec un NIP numérique optionnel
- **Sélecteur de profil** : changement rapide de profil depuis la barre latérale
- **Glisser-déposer les catégories** : réordonnez les catégories ou changez le parent par glisser-déposer dans l'arborescence
- **Ventilation des transactions** : ventilez une transaction sur plusieurs catégories avec des montants ajustables
## 0.2.10
### Nouvelles fonctionnalités
- **Sélection rapide de période** : boutons de filtre rapide (Ce mois, Mois dernier, etc.) sur la page Transactions
- **Rapport Budget vs Réel** : tableau comparatif mensuel et cumulatif annuel dans les Rapports
- **Sous-totaux de catégories parentes** : la page Budget affiche les sous-totaux agrégés pour les catégories parentes
- **Guide utilisateur** : documentation complète accessible depuis les Paramètres, imprimable en PDF
### Améliorations
- Persistance de la sélection de modèle et ajout du bouton Mettre à jour le modèle
- Ne plus pré-sélectionner les fichiers déjà importés à l'entrée de la configuration source
- Rendre les imports de données de paramètres visibles dans l'historique d'import
- Remplacer les boutons de suppression par modèle par un seul bouton sur la sélection
- Remplacer l'icône de rafraîchissement par une icône de sauvegarde sur le bouton de mise à jour du modèle
- Ajout de la convention de signe à la page budget
## 0.2.9
### Corrigé
- Permettre les fichiers à contenu identique avec des noms différents (#1)
## 0.2.8
### Nouvelles fonctionnalités
- **Export/import de données** : exportez et importez vos données (transactions, catégories, ou les deux) avec chiffrement AES-256-GCM optionnel (#3)
### Corrigé
- Détection de doublons inter-fichiers et suivi d'import par fichier
## 0.2.5
### Nouvelles fonctionnalités
- **Modèles de configuration d'import** : sauvegardez et chargez les configurations de source d'import comme modèles réutilisables
- **Grille de budget 12 mois** : vue budgétaire annuelle complète avec cellules mensuelles et totaux annuels
### Corrigé
- Corrections du budget et des catégories
- Problème de somme de contrôle de migration (schema.sql ne doit pas être modifié après la release initiale)
## 0.2.3
### Nouvelles fonctionnalités
- **Motifs de graphiques** : ajout de motifs de remplissage SVG (lignes diagonales, points, hachures, etc.) pour différencier les catégories dans les graphiques au-delà de la couleur
- **Menu contextuel des graphiques** : clic-droit sur une catégorie dans un graphique pour la masquer ou voir ses transactions dans une fenêtre de détail
- **Catégories masquées** : les catégories masquées apparaissent comme des puces au-dessus des graphiques avec un bouton « Tout afficher »
- **Modale de détail des transactions** : visualisez toutes les transactions composant le total d'une catégorie directement depuis n'importe quel graphique
- **Aperçu d'import en popup** : l'aperçu des données est maintenant une modale popup au lieu d'une étape séparée de l'assistant
- **Vérification directe des doublons** : nouveau bouton « Vérifier les doublons » sur la page de configuration d'import
### Améliorations
- Flux de l'assistant d'import simplifié : configuration source → vérification des doublons (l'aperçu est optionnel via popup)
- Le bouton retour de la vérification des doublons retourne maintenant à la configuration source
## 0.2.2
- Mise à jour de version
## 0.2.1
- Ajout de la vue « Tous les mots-clés » sur la page Catégories
- Ajout du mode sombre avec palette de gris chauds
- Correction des catégories orphelines, persistance de has_header pour les imports, ajout de la réinitialisation
- Ajout des pages Budget et Ajustements

View file

@ -2,89 +2,6 @@
## [Unreleased] ## [Unreleased]
## [0.6.6]
### Changed
- Budget table: previous year column now shows actual transactions instead of planned budget (#34)
- Refactored `buildPrevYearTotalMap` inline and simplified tests (#39)
### Fixed
- Changelog files synced automatically via Vite plugin, removed stale public/ copies (#37)
## [0.6.5]
### Added
- Dashboard: month dropdown selector for the Budget vs Actual section with last completed month as default (#31)
### Changed
- Reports & Dashboard: reduced font size of month dropdown for better visual balance (#31)
## [0.6.4]
### Added
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
### Fixed
- Dashboard: level 4+ categories now appear under their parent instead of at the bottom of the section (#23)
- Dashboard: category hierarchy now supports arbitrary nesting depth (#23)
### Changed
- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23)
- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23)
- Budget vs Actual: category column now stays fixed when scrolling horizontally (#29)
- Budget vs Actual: title changed to "Budget vs Réel pour le mois de [month]" with a dropdown month selector (#29)
- Budget vs Actual: default month is now the last completed month instead of current month (#29)
## [0.6.3]
### Added
- Dashboard: expenses over time stacked bar chart by category and month (#15)
- Dashboard: budget vs actual table for current month with variance in $ and % (#15)
- Budget table and Budget vs Actual report: section subtotal formatting with increasing visual weight (#14)
### Changed
- Dashboard: default period changed from "month" to "year to date" (#15)
- Dashboard: removed recent transactions section (#15)
- All report tables: grand total rows now use larger font (text-sm), bold weight, and thicker top border for better visual hierarchy (#14)
### Fixed
- Category over time report: all categories now displayed (limit increased from 8 to 50) (#13)
- Category bar chart: Y-axis labels now use foreground color instead of muted gray (#13)
- Category over time chart: legend text now uses foreground color instead of inheriting category color (#13)
## [0.6.2]
### Added
- Budget table: section subtotals for expenses, income, and transfers displayed after each group (#11)
- Budget vs Actual report: section subtotals with actual, planned, variation ($) and variation (%) per type (#11)
### Fixed
- Category page: detail panel now stays visible when scrolling through a long category list (#12)
## [0.6.1]
### Added
- Changelog page: full version history accessible from Settings at any time
- Bilingual changelog: release notes displayed in the user's selected language (EN/FR)
### Fixed
- Chart label visibility: amount labels on stacked bar charts now use black text with white outline for better contrast (#8)
- Budget table: editable cells now show hover background, pointer cursor, and tooltip hint for clearer affordance (#9)
## [0.6.0]
### Added
- Reports: toggle between table and chart view for Trends, By Category, and Over Time tabs
- Reports: "Show amounts" toggle displays values directly on chart bars and area curves
- Reports: filter panel with category checkboxes (search, select all/none) and source dropdown
- Reports: source filter applies at SQL level for accurate filtered totals
- Reports: sticky table headers on all report tables (Dynamic Report, Budget vs Actual)
- Reports: interactive hover — dimmed non-hovered bars, tooltip filtered to hovered category
- Reports: legend hover highlights category across all months (Over Time chart)
### Fixed
- Transaction table: comment icon now turns orange (like split icon) when a note is present (#7)
## [0.5.0] ## [0.5.0]
### Added ### Added

View file

@ -9,7 +9,7 @@
**Stockage :** SQLite local (tauri-plugin-sql) **Stockage :** SQLite local (tauri-plugin-sql)
**Langues supportées :** Français (FR) et Anglais (EN) **Langues supportées :** Français (FR) et Anglais (EN)
**Plateformes :** Windows, Linux **Plateformes :** Windows, Linux
**Version actuelle :** 0.6.3 **Version actuelle :** 0.5.0
**Licence :** GPL-3.0-only **Licence :** GPL-3.0-only
--- ---
@ -50,7 +50,7 @@ src/
│ └── transactions/ # Transactions │ └── transactions/ # Transactions
├── contexts/ # ProfileContext (état global profil) ├── contexts/ # ProfileContext (état global profil)
├── hooks/ # 12 hooks custom (useReducer) ├── hooks/ # 12 hooks custom (useReducer)
├── pages/ # 11 pages ├── 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/ # Utilitaires (parsing, CSV, charts) ├── utils/ # Utilitaires (parsing, CSV, charts)
@ -92,7 +92,6 @@ src-tauri/
- **Multi-profils** : bases de données séparées, protection par PIN (Argon2), switching rapide - **Multi-profils** : bases de données séparées, protection par PIN (Argon2), switching rapide
- **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF) - **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF)
- **Mises à jour** : auto-updater intégré (tauri-plugin-updater) - **Mises à jour** : auto-updater intégré (tauri-plugin-updater)
- **Changelog bilingue** : page `/changelog` avec historique complet, notes de version dynamiques FR/EN depuis `CHANGELOG.md` / `CHANGELOG.fr.md` (bundlés dans `public/`)
--- ---
@ -136,12 +135,9 @@ La documentation technique est centralisée dans `docs/` :
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/` - Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
- Changement affectant l'utilisation de l'app → mettre à jour `docs/guide-utilisateur.md` et les traductions i18n correspondantes (`src/i18n/locales/fr.json`, `src/i18n/locales/en.json`, clés sous `docs.*`) - Changement affectant l'utilisation de l'app → mettre à jour `docs/guide-utilisateur.md` et les traductions i18n correspondantes (`src/i18n/locales/fr.json`, `src/i18n/locales/en.json`, clés sous `docs.*`)
**Règle CHANGELOG :** tout changement affectant le comportement utilisateur → ajouter une entrée sous `## [Unreleased]` dans **les deux fichiers** : **Règle CHANGELOG :** tout changement affectant le comportement utilisateur → ajouter une entrée sous `## [Unreleased]` dans `CHANGELOG.md`
- `CHANGELOG.md` (anglais) — source principale - Catégories : Added, Changed, Fixed, Removed
- `CHANGELOG.fr.md` (français) — traduction - Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes GitHub et affiché dans l'app.
- Catégories : Added/Ajouté, Changed/Modifié, Fixed/Corrigé, Removed/Supprimé
- Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes et affiché dans l'app selon la langue de l'utilisateur.
- The `public/` copies are synced automatically: Vite copies them on `dev`/`build` start via `syncChangelogs()` in `vite.config.ts`. No manual sync needed.
--- ---

View file

@ -1,6 +1,6 @@
# Architecture technique — Simpl'Résultat # Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-03-07 — Version 0.6.3 > Document mis à jour le 2026-03-01 — Version 0.4.7
## Stack technique ## Stack technique
@ -30,11 +30,11 @@ simpl-resultat/
│ │ ├── adjustments/ # 3 composants │ │ ├── adjustments/ # 3 composants
│ │ ├── budget/ # 5 composants │ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants │ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 2 composants │ │ ├── dashboard/ # 3 composants
│ │ ├── 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/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique) │ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
│ │ ├── settings/ # 3 composants (+ LogViewerCard) │ │ ├── settings/ # 3 composants (+ LogViewerCard)
│ │ ├── shared/ # 6 composants réutilisables │ │ ├── shared/ # 6 composants réutilisables
│ │ └── transactions/ # 5 composants │ │ └── transactions/ # 5 composants
@ -190,7 +190,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| Route | Page | Description | | Route | Page | Description |
|-------|------|-------------| |-------|------|-------------|
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) | | `/` | `DashboardPage` | Tableau de bord avec graphiques |
| `/import` | `ImportPage` | Assistant d'import CSV | | `/import` | `ImportPage` | Assistant d'import CSV |
| `/transactions` | `TransactionsPage` | Liste avec filtres | | `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/categories` | `CategoriesPage` | Gestion hiérarchique | | `/categories` | `CategoriesPage` | Gestion hiérarchique |
@ -199,7 +199,6 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/reports` | `ReportsPage` | Analytique et rapports | | `/reports` | `ReportsPage` | Analytique et rapports |
| `/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) |
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif). Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).

View file

@ -71,9 +71,8 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
- Cartes résumées du solde, des revenus et des dépenses - Cartes résumées du solde, des revenus et des dépenses
- Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG) - Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)
- Tableau Budget vs Réel du mois courant (écart en $ et %) - Liste des transactions récentes
- Histogramme empilé des dépenses par catégorie et par mois - Sélecteur de période ajustable
- Sélecteur de période ajustable (par défaut : année à ce jour)
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions - Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
### Comment faire ### Comment faire
@ -81,13 +80,11 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
1. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.) 1. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
2. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales 2. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
3. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie 3. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
4. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant 4. Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions
5. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps 5. Faites défiler vers le bas pour voir vos transactions les plus récentes
6. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
### Astuces ### Astuces
- La période par défaut est « année à ce jour » pour un aperçu annuel dès l'ouverture
- Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée - Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée
- Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer - Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer
- Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs - Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs

577
package-lock.json generated
View file

@ -1,13 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.6.5", "version": "0.3.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.6.5", "version": "0.3.7",
"license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@ -35,8 +34,7 @@
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.4.1", "vite": "^6.4.1"
"vitest": "^4.0.18"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -861,350 +859,325 @@
"dev": true "dev": true
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@ -1764,17 +1737,6 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@ -1829,13 +1791,6 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1903,127 +1858,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@ -2086,16 +1920,6 @@
} }
] ]
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2288,13 +2112,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.44.0", "version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
@ -2350,31 +2167,11 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2820,29 +2617,11 @@
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true "dev": true
}, },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/papaparse": { "node_modules/papaparse": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==" "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3052,11 +2831,10 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@ -3068,31 +2846,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.57.1",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -3115,13 +2893,6 @@
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3131,20 +2902,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@ -3169,23 +2926,6 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3202,16 +2942,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -3369,84 +3099,6 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/void-elements": { "node_modules/void-elements": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@ -3455,23 +3107,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -1,16 +1,14 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.6.6", "version": "0.5.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri"
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -39,7 +37,6 @@
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.4.1", "vite": "^6.4.1"
"vitest": "^4.0.18"
} }
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "simpl-result" name = "simpl-result"
version = "0.6.6" version = "0.4.4"
description = "Personal finance management app" description = "Personal finance management app"
license = "GPL-3.0-only" license = "GPL-3.0-only"
authors = ["you"] authors = ["you"]

View file

@ -1,6 +1,6 @@
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha384}; use sha2::{Digest, Sha256};
use std::fs; use std::fs;
use tauri::Manager; use tauri::Manager;
@ -157,8 +157,8 @@ fn hex_encode(bytes: &[u8]) -> String {
} }
/// 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 /// Deletes migration records that have mismatched checksums so they can be re-applied.
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords). /// This fixes the "migration X was previously applied but has been modified" error.
#[tauri::command] #[tauri::command]
pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<bool, String> { pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<bool, String> {
let app_dir = app let app_dir = app
@ -174,6 +174,7 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<b
let conn = rusqlite::Connection::open(&db_path) let conn = rusqlite::Connection::open(&db_path)
.map_err(|e| format!("Cannot open database: {}", e))?; .map_err(|e| format!("Cannot open database: {}", e))?;
// Check if _sqlx_migrations table exists
let table_exists: bool = conn let table_exists: bool = conn
.query_row( .query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'", "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'",
@ -186,34 +187,14 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<b
return Ok(false); return Ok(false);
} }
// Current migration SQL — must match the vec in lib.rs // Delete migration 1 record so it can be cleanly re-applied
let migrations: &[(i64, &str)] = &[ // Migration 1 is idempotent (CREATE IF NOT EXISTS + INSERT OR IGNORE)
(1, database::SCHEMA), let deleted = conn
(2, database::SEED_CATEGORIES), .execute(
]; "DELETE FROM _sqlx_migrations WHERE version = 1",
[],
)
.map_err(|e| format!("Cannot repair migrations: {}", e))?;
let mut repaired = false; Ok(deleted > 0)
for (version, sql) in migrations {
let expected_checksum = Sha384::digest(sql.as_bytes()).to_vec();
// Check if this migration exists with a different checksum
let needs_repair: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM _sqlx_migrations WHERE version = ?1 AND checksum != ?2",
rusqlite::params![version, expected_checksum],
|row| row.get(0),
)
.unwrap_or(false);
if needs_repair {
conn.execute(
"UPDATE _sqlx_migrations SET checksum = ?1 WHERE version = ?2",
rusqlite::params![expected_checksum, version],
)
.map_err(|e| format!("Cannot repair migration {}: {}", version, e))?;
repaired = true;
}
}
Ok(repaired)
} }

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.6.6", "version": "0.5.0",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View file

@ -12,7 +12,6 @@ import BudgetPage from "./pages/BudgetPage";
import ReportsPage from "./pages/ReportsPage"; import ReportsPage from "./pages/ReportsPage";
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 ProfileSelectionPage from "./pages/ProfileSelectionPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage"; import ErrorPage from "./components/shared/ErrorPage";
@ -103,7 +102,6 @@ export default function App() {
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<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> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View file

@ -2,7 +2,6 @@ import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react"; import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
style: "currency", style: "currency",
@ -19,6 +18,58 @@ const MONTH_KEYS = [
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[],
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
// Group depth-0 parents with all their descendants, then move subtotals to bottom
const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) {
if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
// Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
// Flush previous sub-group
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
}
interface BudgetTableProps { interface BudgetTableProps {
rows: BudgetYearRow[]; rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
@ -133,16 +184,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
income: "budget.income", income: "budget.income",
transfer: "budget.transfers", transfer: "budget.transfers",
}; };
const typeTotalKeys: Record<string, string> = {
expense: "budget.totalExpenses",
income: "budget.totalIncome",
transfer: "budget.totalTransfers",
};
// Column totals with sign convention (only count leaf rows to avoid double-counting parents) // Column totals with sign convention (only count leaf rows to avoid double-counting parents)
const monthTotals: number[] = Array(12).fill(0); const monthTotals: number[] = Array(12).fill(0);
let annualTotal = 0; let annualTotal = 0;
let prevYearTotal = 0;
for (const row of rows) { for (const row of rows) {
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
const sign = signFor(row.category_type); const sign = signFor(row.category_type);
@ -150,10 +195,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
monthTotals[m] += row.months[m] * sign; monthTotals[m] += row.months[m] * sign;
} }
annualTotal += row.annual * sign; annualTotal += row.annual * sign;
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
} }
const totalCols = 15; // category + prev year + annual + 12 months const totalCols = 14; // category + annual + 12 months
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
@ -179,15 +223,13 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
if (row.is_parent) { if (row.is_parent) {
// Parent subtotal row: read-only, bold, distinct background // Parent subtotal row: read-only, bold, distinct background
const parentDepth = row.depth ?? 0; const parentDepth = row.depth ?? 0;
const isTopParent = parentDepth === 0; const isIntermediateParent = parentDepth === 1;
const isIntermediateParent = parentDepth >= 1;
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
return ( return (
<tr <tr
key={rowKey} key={rowKey}
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`} className={`border-b border-[var(--border)] ${isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--muted)]/30"}`}
> >
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}> <td className={`py-2 sticky left-0 z-10 ${isIntermediateParent ? "pl-8 pr-3 bg-[var(--muted)]/15" : "px-3 bg-[var(--muted)]/30"}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -196,9 +238,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span> <span className={`truncate text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>{row.category_name}</span>
</div> </div>
</td> </td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
{formatSigned(row.previousYearTotal)}
</td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}> <td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(row.annual * sign)} {formatSigned(row.annual * sign)}
</td> </td>
@ -218,7 +257,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors" className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
> >
{/* Category name - sticky */} {/* Category name - sticky */}
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth >= 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}> <td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -227,12 +266,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<span className="truncate text-xs">{row.category_name}</span> <span className="truncate text-xs">{row.category_name}</span>
</div> </div>
</td> </td>
{/* Previous year total — read-only */}
<td className="py-2 px-2 text-right text-[var(--muted-foreground)]">
<span className="text-xs px-1 py-0.5">
{formatSigned(row.previousYearTotal)}
</span>
</td>
{/* Annual total — editable */} {/* Annual total — editable */}
<td className="py-2 px-2 text-right"> <td className="py-2 px-2 text-right">
{editingAnnual?.categoryId === row.category_id ? ( {editingAnnual?.categoryId === row.category_id ? (
@ -250,8 +283,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<button <button
onClick={() => handleStartEditAnnual(row.category_id, row.annual)} onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
title={t("budget.clickToEdit")} className="font-medium text-xs hover:text-[var(--primary)] transition-colors cursor-text"
className="font-medium text-xs hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer rounded px-1 py-0.5"
> >
{formatSigned(row.annual * sign)} {formatSigned(row.annual * sign)}
</button> </button>
@ -283,8 +315,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
) : ( ) : (
<button <button
onClick={() => handleStartEdit(row.category_id, mIdx, val)} onClick={() => handleStartEdit(row.category_id, mIdx, val)}
title={t("budget.clickToEdit")} className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text text-xs"
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
> >
{formatSigned(val * sign)} {formatSigned(val * sign)}
</button> </button>
@ -313,9 +344,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]"> <th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
{t("budget.category")} {t("budget.category")}
</th> </th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.previousYear")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]"> <th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")} {t("budget.annual")}
</th> </th>
@ -330,18 +358,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{typeOrder.map((type) => { {typeOrder.map((type) => {
const group = grouped[type]; const group = grouped[type];
if (!group || group.length === 0) return null; if (!group || group.length === 0) return null;
const sign = signFor(type);
const leaves = group.filter((r) => !r.is_parent);
const sectionMonthTotals: number[] = Array(12).fill(0);
let sectionAnnualTotal = 0;
let sectionPrevYearTotal = 0;
for (const row of leaves) {
for (let m = 0; m < 12; m++) {
sectionMonthTotals[m] += row.months[m] * sign;
}
sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
}
return ( return (
<Fragment key={type}> <Fragment key={type}>
<tr> <tr>
@ -353,28 +369,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</td> </td>
</tr> </tr>
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))} {reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])}
</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold text-[var(--muted-foreground)]">{formatSigned(sectionPrevYearTotal)}</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
{sectionMonthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
{formatSigned(total)}
</td>
))}
</tr>
</Fragment> </Fragment>
); );
})} })}
{/* Totals row */} {/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"> <tr className="bg-[var(--muted)] font-semibold">
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td> <td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td> <td className="py-2.5 px-2 text-right text-xs">{formatSigned(annualTotal)}</td>
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
{monthTotals.map((total, mIdx) => ( {monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-3 px-2 text-right text-sm"> <td key={mIdx} className="py-2.5 px-2 text-right text-xs">
{formatSigned(total)} {formatSigned(total)}
</td> </td>
))} ))}

View file

@ -23,7 +23,6 @@ export default function CategoryPieChart({
}: CategoryPieChartProps) { }: CategoryPieChartProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null); const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [isChartHovered, setIsChartHovered] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name)); const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
@ -37,14 +36,17 @@ export default function CategoryPieChart({
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]"> <div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-6">{t("dashboard.noData")}</p> <h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
</div> </div>
); );
} }
return ( return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]"> <div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
{hiddenCategories.size > 0 && ( {hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-3"> <div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span> <span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
@ -67,12 +69,8 @@ export default function CategoryPieChart({
</div> </div>
)} )}
<div <div onContextMenu={handleContextMenu}>
onContextMenu={handleContextMenu} <ResponsiveContainer width="100%" height={280}>
onMouseEnter={() => setIsChartHovered(true)}
onMouseLeave={() => setIsChartHovered(false)}
>
<ResponsiveContainer width="100%" height={180}>
<PieChart> <PieChart>
<ChartPatternDefs <ChartPatternDefs
prefix="cat-pie" prefix="cat-pie"
@ -84,8 +82,8 @@ export default function CategoryPieChart({
nameKey="category_name" nameKey="category_name"
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={35} innerRadius={50}
outerRadius={75} outerRadius={100}
paddingAngle={2} paddingAngle={2}
> >
{visibleData.map((item, index) => ( {visibleData.map((item, index) => (
@ -99,11 +97,9 @@ export default function CategoryPieChart({
))} ))}
</Pie> </Pie>
<Tooltip <Tooltip
formatter={(value) => { formatter={(value) =>
const formatted = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value)); new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
const pct = total > 0 ? ` (${Math.round((Number(value) / total) * 100)}%)` : ""; }
return `${formatted}${pct}`;
}}
contentStyle={{ contentStyle={{
backgroundColor: "var(--card)", backgroundColor: "var(--card)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
@ -117,14 +113,13 @@ export default function CategoryPieChart({
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2"> <div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
{data.map((item, index) => { {data.map((item, index) => {
const isHidden = hiddenCategories.has(item.category_name); const isHidden = hiddenCategories.has(item.category_name);
const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null;
return ( return (
<button <button
key={index} key={index}
className={`flex items-center gap-1 text-xs ${isHidden ? "opacity-40" : ""}`} className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item }); setContextMenu({ x: e.clientX, y: e.clientY, item });
@ -134,7 +129,7 @@ export default function CategoryPieChart({
> >
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" /> <PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
<span className="text-[var(--muted-foreground)]"> <span className="text-[var(--muted-foreground)]">
{item.category_name}{isChartHovered && pct != null ? ` ${pct}%` : ""} {item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
</span> </span>
</button> </button>
); );

View file

@ -2,7 +2,6 @@ import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types"; import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { new Intl.NumberFormat("en-CA", {
@ -26,6 +25,55 @@ interface BudgetVsActualTableProps {
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[],
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) {
if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
}
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
@ -57,11 +105,6 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
income: t("budget.income"), income: t("budget.income"),
transfer: t("budget.transfers"), transfer: t("budget.transfers"),
}; };
const typeTotalKeys: Record<SectionType, string> = {
expense: "budget.totalExpenses",
income: "budget.totalIncome",
transfer: "budget.totalTransfers",
};
let currentType: SectionType | null = null; let currentType: SectionType | null = null;
for (const row of data) { for (const row of data) {
@ -99,91 +142,69 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")} {subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button> </button>
</div> </div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0 z-20"> <thead>
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> <tr className="border-b border-[var(--border)]">
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]"> <th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom">
{t("budget.category")} {t("budget.category")}
</th> </th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"> <th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("reports.bva.monthly")} {t("reports.bva.monthly")}
</th> </th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"> <th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("reports.bva.ytd")} {t("reports.bva.ytd")}
</th> </th>
</tr> </tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> <tr className="border-b border-[var(--border)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"> <th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("budget.actual")} {t("budget.actual")}
</th> </th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("budget.planned")} {t("budget.planned")}
</th> </th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.dollarVar")} {t("reports.bva.dollarVar")}
</th> </th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.pctVar")} {t("reports.bva.pctVar")}
</th> </th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"> <th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("budget.actual")} {t("budget.actual")}
</th> </th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("budget.planned")} {t("budget.planned")}
</th> </th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.dollarVar")} {t("reports.bva.dollarVar")}
</th> </th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.pctVar")} {t("reports.bva.pctVar")}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sections.map((section) => { {sections.map((section) => (
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
const sectionTotals = sectionLeaves.reduce(
(acc, r) => ({
monthActual: acc.monthActual + r.monthActual,
monthBudget: acc.monthBudget + r.monthBudget,
monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
);
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
return (
<Fragment key={section.type}> <Fragment key={section.type}>
<tr className="bg-[var(--muted)]"> <tr className="bg-[var(--muted)]/50">
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"> <td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider">
{section.label} {section.label}
</td> </td>
</tr> </tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => { {reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent; const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0; const isIntermediateParent = isParent && depth === 1;
const isIntermediateParent = isParent && depth >= 1; const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr <tr
key={`${row.category_id}-${row.is_parent}-${depth}`} key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${ className={`border-b border-[var(--border)]/50 ${
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" : isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" :
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : "" isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
}`} }`}
> >
<td className={`py-1.5 sticky left-0 z-10 ${ <td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}>
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -215,53 +236,29 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr> </tr>
); );
})} })}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{cadFormatter(sectionTotals.monthVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{pctFormatter(sectionMonthPct)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.ytdActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{cadFormatter(sectionTotals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{pctFormatter(sectionYtdPct)}
</td>
</tr>
</Fragment> </Fragment>
); ))}
})}
{/* Grand totals */} {/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"> <tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td> <td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50"> <td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)} {cadFormatter(totals.monthActual)}
</td> </td>
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td> <td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}> <td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
{cadFormatter(totals.monthVariation)} {cadFormatter(totals.monthVariation)}
</td> </td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}> <td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
{pctFormatter(totalMonthPct)} {pctFormatter(totalMonthPct)}
</td> </td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50"> <td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(totals.ytdActual)} {cadFormatter(totals.ytdActual)}
</td> </td>
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td> <td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}> <td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
{cadFormatter(totals.ytdVariation)} {cadFormatter(totals.ytdVariation)}
</td> </td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}> <td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
{pctFormatter(totalYtdPct)} {pctFormatter(totalYtdPct)}
</td> </td>
</tr> </tr>

View file

@ -8,7 +8,6 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Cell, Cell,
LabelList,
} from "recharts"; } from "recharts";
import { Eye } from "lucide-react"; import { Eye } from "lucide-react";
import type { CategoryBreakdownItem } from "../../shared/types"; import type { CategoryBreakdownItem } from "../../shared/types";
@ -24,7 +23,6 @@ interface CategoryBarChartProps {
onToggleHidden: (categoryName: string) => void; onToggleHidden: (categoryName: string) => void;
onShowAll: () => void; onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void; onViewDetails: (item: CategoryBreakdownItem) => void;
showAmounts?: boolean;
} }
export default function CategoryBarChart({ export default function CategoryBarChart({
@ -33,11 +31,9 @@ export default function CategoryBarChart({
onToggleHidden, onToggleHidden,
onShowAll, onShowAll,
onViewDetails, onViewDetails,
showAmounts,
}: CategoryBarChartProps) { }: CategoryBarChartProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null); const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name)); const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
@ -97,7 +93,7 @@ export default function CategoryBarChart({
type="category" type="category"
dataKey="category_name" dataKey="category_name"
width={120} width={120}
tick={{ fill: "var(--foreground)", fontSize: 12 }} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)" stroke="var(--border)"
/> />
<Tooltip <Tooltip
@ -107,9 +103,7 @@ export default function CategoryBarChart({
border: "1px solid var(--border)", border: "1px solid var(--border)",
borderRadius: "8px", borderRadius: "8px",
color: "var(--foreground)", color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}} }}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }} labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }} itemStyle={{ color: "var(--foreground)" }}
/> />
@ -118,21 +112,11 @@ export default function CategoryBarChart({
<Cell <Cell
key={index} key={index}
fill={getPatternFill("cat-bar", index, item.category_color)} fill={getPatternFill("cat-bar", index, item.category_color)}
fillOpacity={hoveredIndex === null || hoveredIndex === index ? 1 : 0.3} onMouseEnter={() => { hoveredRef.current = item; }}
onMouseEnter={() => { hoveredRef.current = item; setHoveredIndex(index); }} onMouseLeave={() => { hoveredRef.current = null; }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredIndex(null); }}
cursor="context-menu" cursor="context-menu"
style={{ transition: "fill-opacity 150ms" }}
/> />
))} ))}
{showAmounts && (
<LabelList
dataKey="total"
position="right"
formatter={(v: unknown) => cadFormatter(Number(v))}
style={{ fill: "var(--foreground)", fontSize: 11 }}
/>
)}
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>

View file

@ -9,7 +9,6 @@ import {
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
CartesianGrid, CartesianGrid,
LabelList,
} from "recharts"; } from "recharts";
import { Eye } from "lucide-react"; import { Eye } from "lucide-react";
import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types"; import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types";
@ -31,7 +30,6 @@ interface CategoryOverTimeChartProps {
onToggleHidden: (categoryName: string) => void; onToggleHidden: (categoryName: string) => void;
onShowAll: () => void; onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void; onViewDetails: (item: CategoryBreakdownItem) => void;
showAmounts?: boolean;
} }
export default function CategoryOverTimeChart({ export default function CategoryOverTimeChart({
@ -40,11 +38,9 @@ export default function CategoryOverTimeChart({
onToggleHidden, onToggleHidden,
onShowAll, onShowAll,
onViewDetails, onViewDetails,
showAmounts,
}: CategoryOverTimeChartProps) { }: CategoryOverTimeChartProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const hoveredRef = useRef<string | null>(null); const hoveredRef = useRef<string | null>(null);
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name)); const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
@ -113,52 +109,28 @@ export default function CategoryOverTimeChart({
width={80} width={80}
/> />
<Tooltip <Tooltip
formatter={(value: unknown, name: unknown) => { formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
if (hoveredCategory && name !== hoveredCategory) return [null, null];
return [cadFormatter(Number(value) || 0), String(name)];
}}
labelFormatter={(label) => formatMonth(String(label))} labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{ contentStyle={{
backgroundColor: "var(--card)", backgroundColor: "var(--card)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
borderRadius: "8px", borderRadius: "8px",
color: "var(--foreground)", color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}} }}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }} labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }} itemStyle={{ color: "var(--foreground)" }}
filterNull
/>
<Legend
onMouseEnter={(e) => {
if (e && e.dataKey) setHoveredCategory(String(e.dataKey));
}}
onMouseLeave={() => setHoveredCategory(null)}
wrapperStyle={{ cursor: "pointer" }}
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
/> />
<Legend />
{categoryEntries.map((c) => ( {categoryEntries.map((c) => (
<Bar <Bar
key={c.name} key={c.name}
dataKey={c.name} dataKey={c.name}
stackId="stack" stackId="stack"
fill={getPatternFill("cat-time", c.index, c.color)} fill={getPatternFill("cat-time", c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2} onMouseEnter={() => { hoveredRef.current = c.name; }}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }} onMouseLeave={() => { hoveredRef.current = null; }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
cursor="context-menu" cursor="context-menu"
style={{ transition: "fill-opacity 150ms" }} />
>
{showAmounts && (
<LabelList
dataKey={c.name}
position="center"
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }}
/>
)}
</Bar>
))} ))}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>

View file

@ -1,111 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CategoryOverTimeData } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
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" });
}
interface CategoryOverTimeTableProps {
data: CategoryOverTimeData;
hiddenCategories?: Set<string>;
}
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
const { t } = useTranslation();
if (data.data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
);
}
const visibleCategories = hiddenCategories?.size
? data.categories.filter((name) => !hiddenCategories.has(name))
: data.categories;
const months = data.data.map((d) => d.month);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<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)] sticky left-0 z-30 min-w-[140px]">
{t("budget.category")}
</th>
{months.map((month) => (
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
{formatMonth(month)}
</th>
))}
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
{t("common.total")}
</th>
</tr>
</thead>
<tbody>
{visibleCategories.map((category) => {
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
return (
<tr key={category} className="border-b border-[var(--border)]/50">
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: data.colors[category] }}
/>
{category}
</span>
</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
return (
<td key={month} className="text-right px-3 py-1.5">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
{cadFormatter(rowTotal)}
</td>
</tr>
);
})}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const monthTotal = visibleCategories.reduce(
(sum, cat) => sum + ((monthData as Record<string, unknown>)?.[cat] as number || 0),
0,
);
return (
<td key={month} className="text-right px-3 py-3">
{cadFormatter(monthTotal)}
</td>
);
})}
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(
visibleCategories.reduce(
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
0,
),
)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,74 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CategoryBreakdownItem } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
interface CategoryTableProps {
data: CategoryBreakdownItem[];
hiddenCategories?: Set<string>;
}
export default function CategoryTable({ data, hiddenCategories }: CategoryTableProps) {
const { t } = useTranslation();
const visibleData = hiddenCategories?.size
? data.filter((d) => !hiddenCategories.has(d.category_name))
: data;
if (visibleData.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
);
}
const grandTotal = visibleData.reduce((sum, row) => sum + row.total, 0);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<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)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("budget.category")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("common.total")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
%
</th>
</tr>
</thead>
<tbody>
{visibleData.map((row) => (
<tr key={row.category_id ?? "uncategorized"} className="border-b border-[var(--border)]/50">
<td className="px-3 py-1.5">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</span>
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.total)}</td>
<td className="text-right px-3 py-1.5 text-[var(--muted-foreground)]">
{grandTotal !== 0 ? `${((row.total / grandTotal) * 100).toFixed(1)}%` : "—"}
</td>
</tr>
))}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td>
<td className="text-right px-3 py-3">{cadFormatter(grandTotal)}</td>
<td className="text-right px-3 py-3 text-[var(--muted-foreground)]">100%</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -149,18 +149,18 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
</button> </button>
</div> </div>
)} )}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0 z-20"> <thead>
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> <tr className="border-b border-[var(--border)]">
{rowDims.map((dim) => ( {rowDims.map((dim) => (
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
{fieldLabel(dim)} {fieldLabel(dim)}
</th> </th>
))} ))}
{colValues.map((colVal) => {colValues.map((colVal) =>
measures.map((m) => ( 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)]"> <th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)}${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)} {colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)}${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
</th> </th>
)) ))
@ -192,13 +192,13 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
)) ))
)} )}
{/* Grand total */} {/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20"> <tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-3"> <td colSpan={rowDims.length || 1} className="px-3 py-2">
{t("reports.pivot.total")} {t("reports.pivot.total")}
</td> </td>
{colValues.map((colVal) => {colValues.map((colVal) =>
measures.map((m) => ( measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50"> <td key={`total-${colVal}-${m}`} className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)} {cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td> </td>
)) ))

View file

@ -7,7 +7,6 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
CartesianGrid, CartesianGrid,
LabelList,
} from "recharts"; } from "recharts";
import type { MonthlyTrendItem } from "../../shared/types"; import type { MonthlyTrendItem } from "../../shared/types";
@ -22,10 +21,9 @@ function formatMonth(month: string): string {
interface MonthlyTrendsChartProps { interface MonthlyTrendsChartProps {
data: MonthlyTrendItem[]; data: MonthlyTrendItem[];
showAmounts?: boolean;
} }
export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsChartProps) { export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (data.length === 0) { if (data.length === 0) {
@ -82,16 +80,7 @@ export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsC
stroke="var(--positive)" stroke="var(--positive)"
fill="url(#gradientIncome)" fill="url(#gradientIncome)"
strokeWidth={2} strokeWidth={2}
> />
{showAmounts && (
<LabelList
dataKey="income"
position="top"
formatter={(v: unknown) => cadFormatter(Number(v))}
style={{ fill: "var(--positive)", fontSize: 10, fontWeight: 600 }}
/>
)}
</Area>
<Area <Area
type="monotone" type="monotone"
dataKey="expenses" dataKey="expenses"
@ -99,16 +88,7 @@ export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsC
stroke="var(--negative)" stroke="var(--negative)"
fill="url(#gradientExpenses)" fill="url(#gradientExpenses)"
strokeWidth={2} strokeWidth={2}
> />
{showAmounts && (
<LabelList
dataKey="expenses"
position="bottom"
formatter={(v: unknown) => cadFormatter(Number(v))}
style={{ fill: "var(--negative)", fontSize: 10, fontWeight: 600 }}
/>
)}
</Area>
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View file

@ -1,77 +0,0 @@
import { useTranslation } from "react-i18next";
import type { MonthlyTrendItem } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
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" });
}
interface MonthlyTrendsTableProps {
data: MonthlyTrendItem[];
}
export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
const { t } = useTranslation();
if (data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
);
}
const totals = data.reduce(
(acc, row) => ({ income: acc.income + row.income, expenses: acc.expenses + row.expenses }),
{ income: 0, expenses: 0 },
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<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)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.pivot.month")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.income")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.expenses")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.net")}
</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.month} className="border-b border-[var(--border)]/50">
<td className="px-3 py-1.5">{formatMonth(row.month)}</td>
<td className="text-right px-3 py-1.5 text-[var(--positive)]">{cadFormatter(row.income)}</td>
<td className="text-right px-3 py-1.5 text-[var(--negative)]">{cadFormatter(row.expenses)}</td>
<td className={`text-right px-3 py-1.5 ${row.income - row.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
{cadFormatter(row.income - row.expenses)}
</td>
</tr>
))}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td>
<td className="text-right px-3 py-3 text-[var(--positive)]">{cadFormatter(totals.income)}</td>
<td className="text-right px-3 py-3 text-[var(--negative)]">{cadFormatter(totals.expenses)}</td>
<td className={`text-right px-3 py-3 ${totals.income - totals.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
{cadFormatter(totals.income - totals.expenses)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,135 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, Search } from "lucide-react";
import type { ImportSource } from "../../shared/types";
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;
}
export default function ReportFilterPanel({
categories,
hiddenCategories,
onToggleHidden,
onShowAll,
sources,
selectedSourceId,
onSourceChange,
}: 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>
)}
{/* 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

@ -196,7 +196,7 @@ export default function TransactionTable({
onClick={() => toggleNotes(row)} onClick={() => toggleNotes(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${ className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
row.notes row.notes
? "text-orange-500" ? "text-[var(--primary)]"
: "text-[var(--muted-foreground)]" : "text-[var(--muted-foreground)]"
}`} }`}
title={t("transactions.notes.placeholder")} title={t("transactions.notes.placeholder")}

View file

@ -3,7 +3,6 @@ import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
import { import {
getAllActiveCategories, getAllActiveCategories,
getBudgetEntriesForYear, getBudgetEntriesForYear,
getActualTotalsForYear,
upsertBudgetEntry, upsertBudgetEntry,
upsertBudgetEntriesForYear, upsertBudgetEntriesForYear,
getAllTemplates, getAllTemplates,
@ -73,10 +72,9 @@ export function useBudget() {
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([ const [allCategories, entries, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -89,13 +87,6 @@ export function useBudget() {
entryMap.get(e.category_id)!.set(e.month, e.amount); entryMap.get(e.category_id)!.set(e.month, e.amount);
} }
// Build a map for previous year actuals: categoryId -> annual actual total
// Amounts are already signed (expenses negative, income positive) — stored as-is.
const prevYearTotalMap = new Map<number, number>();
for (const a of prevYearActuals) {
if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual);
}
// Helper: build months array from entryMap // Helper: build months array from entryMap
const buildMonths = (catId: number) => { const buildMonths = (catId: number) => {
const monthMap = entryMap.get(catId); const monthMap = entryMap.get(catId);
@ -106,8 +97,7 @@ export function useBudget() {
months.push(val); months.push(val);
annual += val; annual += val;
} }
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0; return { months, annual };
return { months, annual, previousYearTotal };
}; };
// Index categories by id and group children by parent_id // Index categories by id and group children by parent_id
@ -127,7 +117,7 @@ export function useBudget() {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) { if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at depth 2 // Leaf at depth 2
const { months, annual, previousYearTotal } = buildMonths(cat.id); const { months, annual } = buildMonths(cat.id);
return [{ return [{
category_id: cat.id, category_id: cat.id,
category_name: cat.name, category_name: cat.name,
@ -138,7 +128,6 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
previousYearTotal,
}]; }];
} }
if (grandchildren.length === 0 && !cat.is_inputable) { if (grandchildren.length === 0 && !cat.is_inputable) {
@ -149,7 +138,7 @@ export function useBudget() {
const gcRows: BudgetYearRow[] = []; const gcRows: BudgetYearRow[] = [];
if (cat.is_inputable) { if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id); const { months, annual } = buildMonths(cat.id);
gcRows.push({ gcRows.push({
category_id: cat.id, category_id: cat.id,
category_name: `${cat.name} (direct)`, category_name: `${cat.name} (direct)`,
@ -160,11 +149,10 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
previousYearTotal,
}); });
} }
for (const gc of grandchildren) { for (const gc of grandchildren) {
const { months, annual, previousYearTotal } = buildMonths(gc.id); const { months, annual } = buildMonths(gc.id);
gcRows.push({ gcRows.push({
category_id: gc.id, category_id: gc.id,
category_name: gc.name, category_name: gc.name,
@ -175,7 +163,6 @@ export function useBudget() {
depth: 2, depth: 2,
months, months,
annual, annual,
previousYearTotal,
}); });
} }
if (gcRows.length === 0) return []; if (gcRows.length === 0) return [];
@ -183,11 +170,9 @@ export function useBudget() {
// Build intermediate subtotal // Build intermediate subtotal
const subMonths = Array(12).fill(0) as number[]; const subMonths = Array(12).fill(0) as number[];
let subAnnual = 0; let subAnnual = 0;
let subPrevYear = 0;
for (const cr of gcRows) { for (const cr of gcRows) {
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m]; for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
subAnnual += cr.annual; subAnnual += cr.annual;
subPrevYear += cr.previousYearTotal;
} }
const subtotal: BudgetYearRow = { const subtotal: BudgetYearRow = {
category_id: cat.id, category_id: cat.id,
@ -199,7 +184,6 @@ export function useBudget() {
depth: 1, depth: 1,
months: subMonths, months: subMonths,
annual: subAnnual, annual: subAnnual,
previousYearTotal: subPrevYear,
}; };
gcRows.sort((a, b) => { gcRows.sort((a, b) => {
if (a.category_id === cat.id) return -1; if (a.category_id === cat.id) return -1;
@ -219,7 +203,7 @@ export function useBudget() {
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) { if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf (no children) — regular editable row // Standalone leaf (no children) — regular editable row
const { months, annual, previousYearTotal } = buildMonths(cat.id); const { months, annual } = buildMonths(cat.id);
rows.push({ rows.push({
category_id: cat.id, category_id: cat.id,
category_name: cat.name, category_name: cat.name,
@ -230,14 +214,13 @@ export function useBudget() {
depth: 0, depth: 0,
months, months,
annual, annual,
previousYearTotal,
}); });
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) { } else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetYearRow[] = []; const allChildRows: BudgetYearRow[] = [];
// If parent is also inputable, create a "(direct)" fake-child row // If parent is also inputable, create a "(direct)" fake-child row
if (cat.is_inputable) { if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id); const { months, annual } = buildMonths(cat.id);
allChildRows.push({ allChildRows.push({
category_id: cat.id, category_id: cat.id,
category_name: `${cat.name} (direct)`, category_name: `${cat.name} (direct)`,
@ -248,7 +231,6 @@ export function useBudget() {
depth: 1, depth: 1,
months, months,
annual, annual,
previousYearTotal,
}); });
} }
@ -256,7 +238,7 @@ export function useBudget() {
const grandchildren = childrenByParent.get(child.id) || []; const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length === 0) { if (grandchildren.length === 0) {
// Simple leaf at depth 1 // Simple leaf at depth 1
const { months, annual, previousYearTotal } = buildMonths(child.id); const { months, annual } = buildMonths(child.id);
allChildRows.push({ allChildRows.push({
category_id: child.id, category_id: child.id,
category_name: child.name, category_name: child.name,
@ -267,7 +249,6 @@ export function useBudget() {
depth: 1, depth: 1,
months, months,
annual, annual,
previousYearTotal,
}); });
} else { } else {
// Intermediate parent at depth 1 with grandchildren // Intermediate parent at depth 1 with grandchildren
@ -286,11 +267,9 @@ export function useBudget() {
const leafRows = allChildRows.filter((r) => !r.is_parent); const leafRows = allChildRows.filter((r) => !r.is_parent);
const parentMonths = Array(12).fill(0) as number[]; const parentMonths = Array(12).fill(0) as number[];
let parentAnnual = 0; let parentAnnual = 0;
let parentPrevYear = 0;
for (const cr of leafRows) { for (const cr of leafRows) {
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m]; for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
parentAnnual += cr.annual; parentAnnual += cr.annual;
parentPrevYear += cr.previousYearTotal;
} }
rows.push({ rows.push({
@ -303,7 +282,6 @@ export function useBudget() {
depth: 0, depth: 0,
months: parentMonths, months: parentMonths,
annual: parentAnnual, annual: parentAnnual,
previousYearTotal: parentPrevYear,
}); });
// Sort children alphabetically, but keep "(direct)" first // Sort children alphabetically, but keep "(direct)" first

View file

@ -3,25 +3,19 @@ import type {
DashboardPeriod, DashboardPeriod,
DashboardSummary, DashboardSummary,
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, RecentTransaction,
BudgetVsActualRow,
} from "../shared/types"; } from "../shared/types";
import { import {
getDashboardSummary, getDashboardSummary,
getExpensesByCategory, getExpensesByCategory,
getRecentTransactions,
} from "../services/dashboardService"; } from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
interface DashboardState { interface DashboardState {
summary: DashboardSummary; summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[]; categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; recentTransactions: RecentTransaction[];
budgetVsActual: BudgetVsActualRow[];
period: DashboardPeriod; period: DashboardPeriod;
budgetYear: number;
budgetMonth: number;
customDateFrom: string; customDateFrom: string;
customDateTo: string; customDateTo: string;
isLoading: boolean; isLoading: boolean;
@ -36,27 +30,22 @@ type DashboardAction =
payload: { payload: {
summary: DashboardSummary; summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[]; categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; recentTransactions: RecentTransaction[];
budgetVsActual: BudgetVsActualRow[];
}; };
} }
| { type: "SET_PERIOD"; payload: DashboardPeriod } | { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }; | { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
const now = new Date(); const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const yearStartStr = `${now.getFullYear()}-01-01`; const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const initialState: DashboardState = { const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 }, summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [], categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, recentTransactions: [],
budgetVsActual: [], period: "month",
period: "year", customDateFrom: monthStartStr,
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
customDateFrom: yearStartStr,
customDateTo: todayStr, customDateTo: todayStr,
isLoading: false, isLoading: false,
error: null, error: null,
@ -73,14 +62,11 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
...state, ...state,
summary: action.payload.summary, summary: action.payload.summary,
categoryBreakdown: action.payload.categoryBreakdown, categoryBreakdown: action.payload.categoryBreakdown,
categoryOverTime: action.payload.categoryOverTime, recentTransactions: action.payload.recentTransactions,
budgetVsActual: action.payload.budgetVsActual,
isLoading: false, isLoading: false,
}; };
case "SET_PERIOD": case "SET_PERIOD":
return { ...state, period: action.payload }; return { ...state, period: action.payload };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_CUSTOM_DATES": case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo }; return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
default: default:
@ -88,32 +74,69 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
} }
} }
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "year":
from = new Date(year, 0, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
default:
from = new Date(year, month, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export function useDashboard() { export function useDashboard() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetchData = useCallback(async ( const fetchData = useCallback(async (period: DashboardPeriod, customFrom?: string, customTo?: string) => {
period: DashboardPeriod,
customFrom: string | undefined,
customTo: string | undefined,
bYear: number,
bMonth: number,
) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([ const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
getDashboardSummary(dateFrom, dateTo), getDashboardSummary(dateFrom, dateTo),
getExpensesByCategory(dateFrom, dateTo), getExpensesByCategory(dateFrom, dateTo),
getCategoryOverTime(dateFrom, dateTo), getRecentTransactions(10),
getBudgetVsActualData(bYear, bMonth),
]); ]);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } }); dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } });
} catch (e) { } catch (e) {
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ dispatch({
@ -124,8 +147,8 @@ export function useDashboard() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth); fetchData(state.period, state.customDateFrom, state.customDateTo);
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]); }, [state.period, state.customDateFrom, state.customDateTo, fetchData]);
const setPeriod = useCallback((period: DashboardPeriod) => { const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period }); dispatch({ type: "SET_PERIOD", payload: period });
@ -135,9 +158,5 @@ export function useDashboard() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } }); dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []); }, []);
const setBudgetMonth = useCallback((year: number, month: number) => { return { state, setPeriod, setCustomDates };
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
}, []);
return { state, setPeriod, setCustomDates, setBudgetMonth };
} }

View file

@ -12,14 +12,12 @@ import type {
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService"; import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService"; import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService"; import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
interface ReportsState { interface ReportsState {
tab: ReportTab; tab: ReportTab;
period: DashboardPeriod; period: DashboardPeriod;
customDateFrom: string; customDateFrom: string;
customDateTo: string; customDateTo: string;
sourceId: number | null;
monthlyTrends: MonthlyTrendItem[]; monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[]; categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
@ -44,8 +42,7 @@ type ReportsAction =
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] } | { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig } | { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
| { type: "SET_PIVOT_RESULT"; payload: PivotResult } | { type: "SET_PIVOT_RESULT"; payload: PivotResult }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } } | { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
| { type: "SET_SOURCE_ID"; payload: number | null };
const now = new Date(); const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
@ -56,12 +53,11 @@ const initialState: ReportsState = {
period: "6months", period: "6months",
customDateFrom: monthStartStr, customDateFrom: monthStartStr,
customDateTo: todayStr, customDateTo: todayStr,
sourceId: null,
monthlyTrends: [], monthlyTrends: [],
categorySpending: [], categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(), budgetYear: now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(), budgetMonth: now.getMonth() + 1,
budgetVsActual: [], budgetVsActual: [],
pivotConfig: { rows: [], columns: [], filters: {}, values: [] }, pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} }, pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
@ -95,13 +91,55 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
return { ...state, pivotResult: action.payload, isLoading: false }; return { ...state, pivotResult: action.payload, isLoading: false };
case "SET_CUSTOM_DATES": case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo }; return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
case "SET_SOURCE_ID":
return { ...state, sourceId: action.payload };
default: default:
return state; return state;
} }
} }
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "year":
from = new Date(year, 0, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
default:
from = new Date(year, month, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export function useReports() { export function useReports() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
@ -114,7 +152,6 @@ export function useReports() {
customFrom?: string, customFrom?: string,
customTo?: string, customTo?: string,
pivotCfg?: PivotConfig, pivotCfg?: PivotConfig,
srcId?: number | null,
) => { ) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
@ -124,21 +161,21 @@ export function useReports() {
switch (tab) { switch (tab) {
case "trends": { case "trends": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined); const data = await getMonthlyTrends(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data }); dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
break; break;
} }
case "byCategory": { case "byCategory": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined); const data = await getExpensesByCategory(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data }); dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
break; break;
} }
case "overTime": { case "overTime": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined); const data = await getCategoryOverTime(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
break; break;
@ -170,8 +207,8 @@ export function useReports() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId); fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]); }, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, fetchData]);
const setTab = useCallback((tab: ReportTab) => { const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab }); dispatch({ type: "SET_TAB", payload: tab });
@ -181,9 +218,18 @@ export function useReports() {
dispatch({ type: "SET_PERIOD", payload: period }); dispatch({ type: "SET_PERIOD", payload: period });
}, []); }, []);
const setBudgetMonth = useCallback((year: number, month: number) => { const navigateBudgetMonth = useCallback((delta: -1 | 1) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } }); let newMonth = state.budgetMonth + delta;
}, []); let newYear = state.budgetYear;
if (newMonth < 1) {
newMonth = 12;
newYear -= 1;
} else if (newMonth > 12) {
newMonth = 1;
newYear += 1;
}
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
}, [state.budgetYear, state.budgetMonth]);
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => { const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } }); dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
@ -193,9 +239,5 @@ export function useReports() {
dispatch({ type: "SET_PIVOT_CONFIG", payload: config }); dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
}, []); }, []);
const setSourceId = useCallback((id: number | null) => { return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig };
dispatch({ type: "SET_SOURCE_ID", payload: id });
}, []);
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
} }

View file

@ -2,11 +2,6 @@
"app": { "app": {
"name": "Simpl'Result" "name": "Simpl'Result"
}, },
"changelog": {
"title": "Version History",
"description": "View what's new and fixed in each version",
"empty": "No entries available"
},
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"import": "Import", "import": "Import",
@ -22,12 +17,9 @@
"balance": "Balance", "balance": "Balance",
"income": "Income", "income": "Income",
"expenses": "Expenses", "expenses": "Expenses",
"net": "Net",
"noData": "No data available. Start by importing your bank statements.", "noData": "No data available. Start by importing your bank statements.",
"expensesByCategory": "Expenses by Category", "expensesByCategory": "Expenses by Category",
"recentTransactions": "Recent Transactions", "recentTransactions": "Recent Transactions",
"budgetVsActual": "Budget vs Actual",
"expensesOverTime": "Expenses Over Time",
"period": { "period": {
"month": "This month", "month": "This month",
"3months": "3 months", "3months": "3 months",
@ -319,18 +311,13 @@
"actual": "Actual", "actual": "Actual",
"difference": "Difference", "difference": "Difference",
"annual": "Annual", "annual": "Annual",
"previousYear": "Prev. Year",
"splitEvenly": "Split evenly across 12 months", "splitEvenly": "Split evenly across 12 months",
"annualMismatch": "Annual total does not match the sum of monthly amounts", "annualMismatch": "Annual total does not match the sum of monthly amounts",
"clickToEdit": "Click to edit",
"applyToMonth": "Apply to month", "applyToMonth": "Apply to month",
"allMonths": "All 12 months", "allMonths": "All 12 months",
"expenses": "Expenses", "expenses": "Expenses",
"income": "Income", "income": "Income",
"transfers": "Transfers", "transfers": "Transfers",
"totalExpenses": "Total Expenses",
"totalIncome": "Total Income",
"totalTransfers": "Total Transfers",
"totalPlanned": "Total Planned", "totalPlanned": "Total Planned",
"totalActual": "Total Actual", "totalActual": "Total Actual",
"totalDifference": "Difference", "totalDifference": "Difference",
@ -366,19 +353,12 @@
"showAmounts": "Show amounts", "showAmounts": "Show amounts",
"hideAmounts": "Hide amounts" "hideAmounts": "Hide amounts"
}, },
"filters": {
"title": "Categories",
"search": "Search...",
"all": "All",
"none": "None"
},
"bva": { "bva": {
"monthly": "Monthly", "monthly": "Monthly",
"ytd": "Year-to-Date", "ytd": "Year-to-Date",
"dollarVar": "$ Var", "dollarVar": "$ Var",
"pctVar": "% Var", "pctVar": "% Var",
"noData": "No budget or transaction data for this period.", "noData": "No budget or transaction data for this period."
"titlePrefix": "Budget vs Actual for"
}, },
"dynamic": "Dynamic Report", "dynamic": "Dynamic Report",
"export": "Export", "export": "Export",
@ -435,7 +415,14 @@
"installing": "Installing...", "installing": "Installing...",
"error": "Update failed", "error": "Update failed",
"retryButton": "Retry", "retryButton": "Retry",
"releaseNotes": "What's New" "releaseNotes": "What's New",
"notes": {
"0.4.0": "### Added\n- Categories: support for 3 levels of hierarchy (e.g., Recurring Expenses → Insurance → Car Insurance)\n- Dynamic Report: new \"Category (Level 3)\" pivot field\n- Budget: intermediate subtotals and 3-level indentation for nested categories\n- Categories: automatic `is_inputable` management when creating/deleting subcategories\n- Categories: depth validation prevents creating a 4th level\n\n### Fixed\n- Auto-categorization: keywords starting/ending with special characters (`[`, `]`, `(`, `)`, `-`, etc.) now match correctly\n- Auto-categorization: pre-compile regex patterns for better batch performance",
"0.3.11": "### Added\n- Dynamic Report: support multiple column dimensions (composite column keys)\n\n### Fixed\n- Dynamic Report: no longer affected by global page date filters — uses only its own panel filters",
"0.3.10": "### Added\n- Dynamic Report: fields can now be used in multiple zones simultaneously (rows + filters, columns + filters)\n- Dynamic Report: right-click on a filter value to exclude it (shown with strikethrough in red)\n- \"This year\" period option in reports and dashboard (Jan 1 to today)",
"0.3.9": "### Added\n- Dynamic Report (pivot table): compose custom reports by assigning dimensions to rows, columns, filters and measures to values\n- Delete keywords from the \"All Keywords\" view",
"0.3.8": "### Added\n- Custom date range picker for reports and dashboard\n- Toggle to position subtotals above or below detail rows\n- Display release notes from CHANGELOG in GitHub releases and in-app updater"
}
}, },
"dataManagement": { "dataManagement": {
"title": "Data Management", "title": "Data Management",
@ -590,21 +577,18 @@
"features": [ "features": [
"Balance, income, and expense summary cards", "Balance, income, and expense summary cards",
"Expense breakdown by category (pie chart with SVG patterns)", "Expense breakdown by category (pie chart with SVG patterns)",
"Budget vs Actual table for the current month (variance in $ and %)", "Recent transactions list",
"Stacked bar chart of expenses by category and month", "Adjustable time period selector",
"Adjustable time period selector (default: year to date)",
"Context menu (right-click) to hide a category or view its transactions" "Context menu (right-click) to hide a category or view its transactions"
], ],
"steps": [ "steps": [
"Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)", "Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)",
"Review the summary cards for your balance, total income, and total expenses", "Review the summary cards for your balance, total income, and total expenses",
"Check the pie chart to see how your spending is distributed across categories", "Check the pie chart to see how your spending is distributed across categories",
"Review the Budget vs Actual table to compare your spending against your plan for the current month", "Right-click a category in the chart to hide it or view its transaction details",
"Analyze the stacked bar chart at the bottom to see expense trends by category over time", "Scroll down to see your most recent transactions"
"Right-click a category in a chart to hide it or view its transaction details"
], ],
"tips": [ "tips": [
"The default period is year to date for an annual overview when you open the app",
"The balance is calculated as income minus expenses for the selected period", "The balance is calculated as income minus expenses for the selected period",
"Hidden categories appear as dismissible chips above the chart — click Show All to restore them", "Hidden categories appear as dismissible chips above the chart — click Show All to restore them",
"SVG patterns (lines, dots, crosshatch) help distinguish categories beyond just color" "SVG patterns (lines, dots, crosshatch) help distinguish categories beyond just color"

View file

@ -2,11 +2,6 @@
"app": { "app": {
"name": "Simpl'Résultat" "name": "Simpl'Résultat"
}, },
"changelog": {
"title": "Historique des versions",
"description": "Consultez les nouveautés et corrections de chaque version",
"empty": "Aucune entrée disponible"
},
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
"import": "Importer", "import": "Importer",
@ -22,12 +17,9 @@
"balance": "Solde", "balance": "Solde",
"income": "Revenus", "income": "Revenus",
"expenses": "Dépenses", "expenses": "Dépenses",
"net": "Net",
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.", "noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
"expensesByCategory": "Dépenses par catégorie", "expensesByCategory": "Dépenses par catégorie",
"recentTransactions": "Transactions récentes", "recentTransactions": "Transactions récentes",
"budgetVsActual": "Budget vs Réel",
"expensesOverTime": "Dépenses dans le temps",
"period": { "period": {
"month": "Ce mois", "month": "Ce mois",
"3months": "3 mois", "3months": "3 mois",
@ -319,18 +311,13 @@
"actual": "Réel", "actual": "Réel",
"difference": "Écart", "difference": "Écart",
"annual": "Annuel", "annual": "Annuel",
"previousYear": "Année préc.",
"splitEvenly": "Répartir également sur 12 mois", "splitEvenly": "Répartir également sur 12 mois",
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels", "annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
"clickToEdit": "Cliquer pour modifier",
"applyToMonth": "Appliquer au mois", "applyToMonth": "Appliquer au mois",
"allMonths": "Les 12 mois", "allMonths": "Les 12 mois",
"expenses": "Dépenses", "expenses": "Dépenses",
"income": "Revenus", "income": "Revenus",
"transfers": "Transferts", "transfers": "Transferts",
"totalExpenses": "Total des dépenses",
"totalIncome": "Total des revenus",
"totalTransfers": "Total des transferts",
"totalPlanned": "Total prévu", "totalPlanned": "Total prévu",
"totalActual": "Total réel", "totalActual": "Total réel",
"totalDifference": "Écart", "totalDifference": "Écart",
@ -366,19 +353,12 @@
"showAmounts": "Afficher les montants", "showAmounts": "Afficher les montants",
"hideAmounts": "Masquer les montants" "hideAmounts": "Masquer les montants"
}, },
"filters": {
"title": "Catégories",
"search": "Rechercher...",
"all": "Toutes",
"none": "Aucune"
},
"bva": { "bva": {
"monthly": "Mensuel", "monthly": "Mensuel",
"ytd": "Cumul annuel", "ytd": "Cumul annuel",
"dollarVar": "$ \u00c9cart", "dollarVar": "$ \u00c9cart",
"pctVar": "% \u00c9cart", "pctVar": "% \u00c9cart",
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.", "noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
"titlePrefix": "Budget vs Réel pour le mois de"
}, },
"dynamic": "Rapport dynamique", "dynamic": "Rapport dynamique",
"export": "Exporter", "export": "Exporter",
@ -435,7 +415,14 @@
"installing": "Installation en cours...", "installing": "Installation en cours...",
"error": "Erreur lors de la mise à jour", "error": "Erreur lors de la mise à jour",
"retryButton": "Réessayer", "retryButton": "Réessayer",
"releaseNotes": "Nouveautés" "releaseNotes": "Nouveautés",
"notes": {
"0.4.0": "### Ajouté\n- Catégories : support de 3 niveaux de hiérarchie (ex : Dépenses récurrentes → Assurances → Assurance-auto)\n- Rapport dynamique : nouveau champ « Catégorie (Niveau 3) »\n- Budget : sous-totaux intermédiaires et indentation 3 niveaux\n- Catégories : gestion automatique de `is_inputable` à la création/suppression de sous-catégories\n- Catégories : validation de profondeur empêche la création d'un 4e niveau\n\n### Corrigé\n- Auto-catégorisation : les mots-clés commençant/finissant par des caractères spéciaux (`[`, `]`, `(`, `)`, `-`, etc.) sont maintenant reconnus\n- Auto-catégorisation : pré-compilation des regex pour de meilleures performances en lot",
"0.3.11": "### Ajouté\n- Rapport dynamique : support de plusieurs dimensions en colonnes (clés composites)\n\n### Corrigé\n- Rapport dynamique : n'est plus affecté par les filtres de date globaux — utilise uniquement ses propres filtres du panneau",
"0.3.10": "### Ajouté\n- Rapport dynamique : les champs peuvent maintenant être utilisés dans plusieurs zones simultanément (lignes + filtres, colonnes + filtres)\n- Rapport dynamique : clic-droit sur une valeur de filtre pour l'exclure (affiché barré en rouge)\n- Option de période « Cette année » dans les rapports et le tableau de bord (du 1er janvier à aujourd'hui)",
"0.3.9": "### Ajouté\n- Rapport dynamique (tableau croisé) : composez des rapports personnalisés en assignant des dimensions aux lignes, colonnes, filtres et mesures aux valeurs\n- Suppression de mots-clés depuis la vue « Tous les mots-clés »",
"0.3.8": "### Ajouté\n- Sélecteur de plage de dates personnalisée pour les rapports et le tableau de bord\n- Bascule pour positionner les sous-totaux au-dessus ou en dessous des lignes de détail\n- Affichage des notes de version du CHANGELOG dans les releases GitHub et le système de mise à jour"
}
}, },
"dataManagement": { "dataManagement": {
"title": "Gestion des données", "title": "Gestion des données",
@ -590,21 +577,18 @@
"features": [ "features": [
"Cartes résumées du solde, des revenus et des dépenses", "Cartes résumées du solde, des revenus et des dépenses",
"Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)", "Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)",
"Tableau Budget vs Réel du mois courant (écart en $ et %)", "Liste des transactions récentes",
"Histogramme empilé des dépenses par catégorie et par mois", "Sélecteur de période ajustable",
"Sélecteur de période ajustable (par défaut : année à ce jour)",
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions" "Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions"
], ],
"steps": [ "steps": [
"Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)", "Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)",
"Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales", "Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales",
"Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie", "Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie",
"Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant", "Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions",
"Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps", "Faites défiler vers le bas pour voir vos transactions les plus récentes"
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions"
], ],
"tips": [ "tips": [
"La période par défaut est « année à ce jour » pour un aperçu annuel dès l'ouverture",
"Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée", "Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée",
"Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer", "Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer",
"Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs" "Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs"

View file

@ -90,7 +90,7 @@ export default function CategoriesPage() {
onRemove={removeKeyword} onRemove={removeKeyword}
/> />
) : ( ) : (
<div className="flex gap-6" style={{ height: "calc(100vh - 180px)" }}> <div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto"> <div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
<CategoryTree <CategoryTree
tree={state.tree} tree={state.tree}

View file

@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
interface ChangelogEntry {
version: string;
sections: { heading: string; items: string[] }[];
}
function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: { heading: string; items: string[] } | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
// Version heading: ## [0.6.0] or ## 0.6.0
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
// Section heading: ### Added, ### Corrigé, etc.
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
// List item
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
export default function ChangelogPage() {
const { t, i18n } = useTranslation();
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
useEffect(() => {
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => setEntries(parseChangelog(text)))
.catch(() => setEntries([]));
}, [i18n.language]);
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Link
to="/settings"
className="p-1.5 rounded-lg hover:bg-[var(--muted)] transition-colors"
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("changelog.title")}</h1>
</div>
{entries.length === 0 ? (
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
) : (
<div className="space-y-6">
{entries.map((entry) => (
<div
key={entry.version}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
>
<h2 className="text-lg font-semibold">{entry.version}</h2>
{entry.sections.map((section, si) => (
<div key={si} className="space-y-1.5">
<h3 className="text-sm font-semibold text-[var(--primary)]">
{section.heading}
</h3>
<ul className="space-y-1">
{section.items.map((item, ii) => (
<li
key={ii}
className="text-sm text-[var(--muted-foreground)] pl-3"
>
{"\u2022 "}
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1,22 +1,47 @@
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wallet, TrendingUp, TrendingDown } from "lucide-react"; import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { useDashboard } from "../hooks/useDashboard"; import { useDashboard } from "../hooks/useDashboard";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart"; import CategoryPieChart from "../components/dashboard/CategoryPieChart";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import TransactionDetailModal from "../components/shared/TransactionDetailModal"; import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem } from "../shared/types"; import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }); const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month": from = new Date(year, month, 1); break;
case "3months": from = new Date(year, month - 2, 1); break;
case "6months": from = new Date(year, month - 5, 1); break;
case "year": from = new Date(year, 0, 1); break;
case "12months": from = new Date(year, month - 11, 1); break;
default: from = new Date(year, month, 1); break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export default function DashboardPage() { export default function DashboardPage() {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard(); const { state, setPeriod, setCustomDates } = useDashboard();
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state; const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null); const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -65,8 +90,6 @@ export default function DashboardPage() {
}, },
]; ];
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo); const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
return ( return (
@ -85,7 +108,7 @@ export default function DashboardPage() {
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{cards.map((card) => ( {cards.map((card) => (
<div <div
key={card.labelKey} key={card.labelKey}
@ -102,48 +125,15 @@ export default function DashboardPage() {
))} ))}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="lg:col-span-1"> <CategoryPieChart
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2> data={categoryBreakdown}
<CategoryPieChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
</div>
<div className="lg:col-span-3">
<h2 className="text-lg font-semibold mb-3 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-base font-semibold 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>
</h2>
<BudgetVsActualTable data={budgetVsActual} />
</div>
</div>
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesOverTime")}</h2>
<CategoryOverTimeChart
data={categoryOverTime}
hiddenCategories={hiddenCategories} hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden} onToggleHidden={toggleHidden}
onShowAll={showAll} onShowAll={showAll}
onViewDetails={viewDetails} onViewDetails={viewDetails}
/> />
<RecentTransactionsList transactions={recentTransactions} />
</div> </div>
{detailModal && ( {detailModal && (

View file

@ -1,40 +1,52 @@
import { useState, useCallback, useMemo, useEffect } from "react"; import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Hash, Table, BarChart3 } from "lucide-react";
import { useReports } from "../hooks/useReports"; 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 type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
import { getAllSources } from "../services/importSourceService";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthNavigator from "../components/budget/MonthNavigator";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryBarChart from "../components/reports/CategoryBarChart"; import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoryTable from "../components/reports/CategoryTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable"; import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import DynamicReport from "../components/reports/DynamicReport"; import DynamicReport from "../components/reports/DynamicReport";
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
import TransactionDetailModal from "../components/shared/TransactionDetailModal"; import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"]; const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
export default function ReportsPage() { function computeDateRange(
const { t, i18n } = useTranslation(); period: DashboardPeriod,
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports(); customDateFrom?: string,
const [sources, setSources] = useState<ImportSource[]>([]); customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month": from = new Date(year, month, 1); break;
case "3months": from = new Date(year, month - 2, 1); break;
case "6months": from = new Date(year, month - 5, 1); break;
case "year": from = new Date(year, 0, 1); break;
case "12months": from = new Date(year, month - 11, 1); break;
default: from = new Date(year, month, 1); break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
useEffect(() => { export default function ReportsPage() {
getAllSources().then(setSources); const { t } = useTranslation();
}, []); const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig } = useReports();
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null); const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
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"
);
const toggleHidden = useCallback((name: string) => { const toggleHidden = useCallback((name: string) => {
setHiddenCategories((prev) => { setHiddenCategories((prev) => {
@ -53,52 +65,20 @@ export default function ReportsPage() {
const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo); const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo);
const filterCategories = useMemo(() => {
if (state.tab === "byCategory") {
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 className={state.isLoading ? "opacity-50 pointer-events-none" : ""}> <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">
{state.tab === "budgetVsActual" ? ( <h1 className="text-2xl font-bold">{t("reports.title")}</h1>
<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" && ( {state.tab === "budgetVsActual" ? (
<MonthNavigator
year={state.budgetYear}
month={state.budgetMonth}
onNavigate={navigateBudgetMonth}
/>
) : (
<PeriodSelector <PeriodSelector
value={state.period} value={state.period}
onChange={setPeriod} onChange={setPeriod}
@ -109,7 +89,7 @@ export default function ReportsPage() {
)} )}
</div> </div>
<div className="flex gap-2 mb-6 flex-wrap items-center"> <div className="flex gap-2 mb-6 flex-wrap">
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button
key={tab} key={tab}
@ -123,54 +103,6 @@ export default function ReportsPage() {
{t(`reports.${tab}`)} {t(`reports.${tab}`)}
</button> </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>
{state.error && ( {state.error && (
@ -179,66 +111,35 @@ export default function ReportsPage() {
</div> </div>
)} )}
<div className={showFilterPanel ? "flex gap-4 items-start" : ""}> {state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
<div className={showFilterPanel ? "flex-1 min-w-0" : ""}> {state.tab === "byCategory" && (
{state.tab === "trends" && ( <CategoryBarChart
viewMode === "chart" ? ( data={state.categorySpending}
<MonthlyTrendsChart data={state.monthlyTrends} showAmounts={showAmounts} /> hiddenCategories={hiddenCategories}
) : ( onToggleHidden={toggleHidden}
<MonthlyTrendsTable data={state.monthlyTrends} /> onShowAll={showAll}
) onViewDetails={viewDetails}
)} />
{state.tab === "byCategory" && ( )}
viewMode === "chart" ? ( {state.tab === "overTime" && (
<CategoryBarChart <CategoryOverTimeChart
data={state.categorySpending} data={state.categoryOverTime}
hiddenCategories={hiddenCategories} hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden} onToggleHidden={toggleHidden}
onShowAll={showAll} onShowAll={showAll}
onViewDetails={viewDetails} onViewDetails={viewDetails}
showAmounts={showAmounts} />
/> )}
) : ( {state.tab === "budgetVsActual" && (
<CategoryTable data={state.categorySpending} hiddenCategories={hiddenCategories} /> <BudgetVsActualTable data={state.budgetVsActual} />
) )}
)} {state.tab === "dynamic" && (
{state.tab === "overTime" && ( <DynamicReport
viewMode === "chart" ? ( config={state.pivotConfig}
<CategoryOverTimeChart result={state.pivotResult}
data={state.categoryOverTime} onConfigChange={setPivotConfig}
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}
/>
)}
</div>
{detailModal && ( {detailModal && (
<TransactionDetailModal <TransactionDetailModal

View file

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Info, Info,
@ -11,7 +11,6 @@ import {
ShieldCheck, ShieldCheck,
BookOpen, BookOpen,
ChevronRight, ChevronRight,
FileText,
} from "lucide-react"; } from "lucide-react";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { useUpdater } from "../hooks/useUpdater"; import { useUpdater } from "../hooks/useUpdater";
@ -22,44 +21,15 @@ import DataManagementCard from "../components/settings/DataManagementCard";
import LogViewerCard from "../components/settings/LogViewerCard"; import LogViewerCard from "../components/settings/LogViewerCard";
export default function SettingsPage() { export default function SettingsPage() {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const { state, checkForUpdate, downloadAndInstall, installAndRestart } = const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
useUpdater(); useUpdater();
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
const fetchReleaseNotes = useCallback(
(targetVersion: string) => {
const file =
i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => {
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(
`^## \\[?${escaped}\\]?.*$\\n([\\s\\S]*?)(?=^## |$(?!\\n))`,
"m",
);
const match = text.match(re);
setReleaseNotes(
match ? match[1].trim() : null,
);
})
.catch(() => setReleaseNotes(null));
},
[i18n.language],
);
useEffect(() => { useEffect(() => {
getVersion().then(setVersion); getVersion().then(setVersion);
}, []); }, []);
useEffect(() => {
if (state.status === "available" && state.version) {
fetchReleaseNotes(state.version);
}
}, [state.status, state.version, fetchReleaseNotes]);
const progressPercent = const progressPercent =
state.contentLength && state.contentLength > 0 state.contentLength && state.contentLength > 0
? Math.round((state.progress / state.contentLength) * 100) ? Math.round((state.progress / state.contentLength) * 100)
@ -108,27 +78,6 @@ export default function SettingsPage() {
</div> </div>
</Link> </Link>
{/* Changelog card */}
<Link
to="/changelog"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<FileText size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">{t("changelog.title")}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("changelog.description")}
</p>
</div>
</div>
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
</div>
</Link>
{/* Update card */} {/* Update card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4"> <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"> <h2 className="text-lg font-semibold flex items-center gap-2">
@ -178,7 +127,9 @@ export default function SettingsPage() {
{t("settings.updates.available", { version: state.version })} {t("settings.updates.available", { version: state.version })}
</p> </p>
{(() => { {(() => {
const notes = releaseNotes || state.body; const notesKey = `settings.updates.notes.${state.version}`;
const i18nNotes = t(notesKey, { defaultValue: "" });
const notes = i18nNotes || state.body;
if (!notes) return null; if (!notes) return null;
return ( return (
<div className="space-y-2"> <div className="space-y-2">

View file

@ -178,16 +178,6 @@ export async function deleteTemplate(templateId: number): Promise<void> {
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]); await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
} }
// --- Actuals helpers ---
export async function getActualTotalsForYear(
year: number
): Promise<Array<{ category_id: number | null; actual: number }>> {
const dateFrom = `${year}-01-01`;
const dateTo = `${year}-12-31`;
return getActualsByCategoryRange(dateFrom, dateTo);
}
// --- Budget vs Actual --- // --- Budget vs Actual ---
async function getActualsByCategoryRange( async function getActualsByCategoryRange(
@ -241,6 +231,7 @@ export async function getBudgetVsActualData(
} }
// Index categories // Index categories
const catById = new Map(allCategories.map((c) => [c.id, c]));
const childrenByParent = new Map<number, Category[]>(); const childrenByParent = new Map<number, Category[]>();
for (const cat of allCategories) { for (const cat of allCategories) {
if (cat.parent_id) { if (cat.parent_id) {
@ -253,7 +244,7 @@ export async function getBudgetVsActualData(
const signFor = (type: string) => (type === "expense" ? -1 : 1); const signFor = (type: string) => (type === "expense" ? -1 : 1);
// Compute leaf row values // Compute leaf row values
function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow { function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
const sign = signFor(cat.type); const sign = signFor(cat.type);
const monthMap = entryMap.get(cat.id); const monthMap = entryMap.get(cat.id);
const rawMonthBudget = monthMap?.get(month) ?? 0; const rawMonthBudget = monthMap?.get(month) ?? 0;
@ -290,7 +281,7 @@ export async function getBudgetVsActualData(
}; };
} }
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow { function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
const row: BudgetVsActualRow = { const row: BudgetVsActualRow = {
category_id: cat.id, category_id: cat.id,
category_name: cat.name, category_name: cat.name,
@ -332,41 +323,35 @@ export async function getBudgetVsActualData(
); );
} }
// Build rows for a sub-group (recursive, supports arbitrary depth) // Build rows for a level-2 parent (intermediate parent with grandchildren)
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] { function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] {
const subChildren = childrenByParent.get(cat.id) || []; const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
const hasSubChildren = subChildren.some( if (grandchildren.length === 0 && cat.is_inputable) {
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0 // Leaf at level 2
); const leaf = buildLeaf(cat, grandparentId, 2);
if (!hasSubChildren && cat.is_inputable) {
const leaf = buildLeaf(cat, groupParentId, depth);
return isRowAllZero(leaf) ? [] : [leaf]; return isRowAllZero(leaf) ? [] : [leaf];
} }
if (!hasSubChildren) return []; if (grandchildren.length === 0) return [];
const childRows: BudgetVsActualRow[] = []; const gcRows: BudgetVsActualRow[] = [];
if (cat.is_inputable) { if (cat.is_inputable) {
const direct = buildLeaf(cat, cat.id, depth + 1); const direct = buildLeaf(cat, cat.id, 2);
direct.category_name = `${cat.name} (direct)`; direct.category_name = `${cat.name} (direct)`;
if (!isRowAllZero(direct)) childRows.push(direct); if (!isRowAllZero(direct)) gcRows.push(direct);
} }
const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name)); for (const gc of grandchildren) {
for (const child of sortedSubChildren) { const leaf = buildLeaf(gc, cat.id, 2);
const grandchildren = childrenByParent.get(child.id) || []; if (!isRowAllZero(leaf)) gcRows.push(leaf);
if (grandchildren.length > 0) {
const subRows = buildSubGroup(child, cat.id, depth + 1);
childRows.push(...subRows);
} else if (child.is_inputable) {
const leaf = buildLeaf(child, cat.id, depth + 1);
if (!isRowAllZero(leaf)) childRows.push(leaf);
}
} }
if (childRows.length === 0) return []; if (gcRows.length === 0) return [];
const leafRows = childRows.filter((r) => !r.is_parent); const subtotal = buildSubtotal(cat, gcRows, grandparentId, 1);
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth); gcRows.sort((a, b) => {
return [subtotal, ...childRows]; if (a.category_id === cat.id) return -1;
if (b.category_id === cat.id) return 1;
return a.category_name.localeCompare(b.category_name);
});
return [subtotal, ...gcRows];
} }
const rows: BudgetVsActualRow[] = []; const rows: BudgetVsActualRow[] = [];
@ -374,15 +359,15 @@ export async function getBudgetVsActualData(
for (const cat of topLevel) { for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || []; const children = childrenByParent.get(cat.id) || [];
const hasChildren = children.some( const inputableChildren = children.filter((c) => c.is_inputable);
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0 // Also check for non-inputable intermediate parents that have their own children
); const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0);
if (!hasChildren && cat.is_inputable) { if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf at level 0 // Standalone leaf at level 0
const leaf = buildLeaf(cat, null, 0); const leaf = buildLeaf(cat, null, 0);
if (!isRowAllZero(leaf)) rows.push(leaf); if (!isRowAllZero(leaf)) rows.push(leaf);
} else if (hasChildren) { } else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetVsActualRow[] = []; const allChildRows: BudgetVsActualRow[] = [];
// Direct transactions on the parent itself // Direct transactions on the parent itself
@ -392,19 +377,25 @@ export async function getBudgetVsActualData(
if (!isRowAllZero(direct)) allChildRows.push(direct); if (!isRowAllZero(direct)) allChildRows.push(direct);
} }
// Process children in alphabetical order // Level-2 leaves (direct children that are inputable and have no children)
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name)); for (const child of inputableChildren) {
for (const child of sortedChildren) {
const grandchildren = childrenByParent.get(child.id) || []; const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length > 0) { if (grandchildren.length === 0) {
const subRows = buildSubGroup(child, cat.id, 1);
allChildRows.push(...subRows);
} else if (child.is_inputable) {
const leaf = buildLeaf(child, cat.id, 1); const leaf = buildLeaf(child, cat.id, 1);
if (!isRowAllZero(leaf)) allChildRows.push(leaf); if (!isRowAllZero(leaf)) allChildRows.push(leaf);
} else {
// This child has its own children — it's an intermediate parent at level 1
const subRows = buildLevel2Group(child, cat.id);
allChildRows.push(...subRows);
} }
} }
// Non-inputable intermediate parents at level 1
for (const ip of intermediateParents) {
const subRows = buildLevel2Group(ip, cat.id);
allChildRows.push(...subRows);
}
if (allChildRows.length === 0) continue; if (allChildRows.length === 0) continue;
// Collect only leaf rows for parent subtotal (avoid double-counting) // Collect only leaf rows for parent subtotal (avoid double-counting)
@ -412,19 +403,51 @@ export async function getBudgetVsActualData(
const parent = buildSubtotal(cat, leafRows, null, 0); const parent = buildSubtotal(cat, leafRows, null, 0);
rows.push(parent); rows.push(parent);
// Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves
allChildRows.sort((a, b) => {
if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name);
});
rows.push(...allChildRows); rows.push(...allChildRows);
} }
} }
// Sort by type only, preserving tree order within groups (already built correctly) // Sort by type, then within same type keep parent+children groups together
const rowOrder = new Map<BudgetVsActualRow, number>();
rows.forEach((r, i) => rowOrder.set(r, i));
rows.sort((a, b) => { rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9; const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9; const typeB = TYPE_ORDER[b.category_type] ?? 9;
if (typeA !== typeB) return typeA - typeB; if (typeA !== typeB) return typeA - typeB;
return rowOrder.get(a)! - rowOrder.get(b)!; // Find the top-level group id
function getGroupId(r: BudgetVsActualRow): number {
if (r.depth === 0) return r.category_id;
if (r.is_parent && r.parent_id === null) return r.category_id;
// Walk up to find the root
let pid = r.parent_id;
while (pid !== null) {
const pCat = catById.get(pid);
if (!pCat || !pCat.parent_id) return pid;
pid = pCat.parent_id;
}
return r.category_id;
}
const groupA = getGroupId(a);
const groupB = getGroupId(b);
if (groupA !== groupB) {
const catA = catById.get(groupA);
const catB = catById.get(groupB);
const orderA = catA?.sort_order ?? 999;
const orderB = catB?.sort_order ?? 999;
if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
}
// Within same group: sort by depth, then parent before children
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
if (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.parent_id) return 1;
return a.category_name.localeCompare(b.category_name);
}); });
return rows; return rows;

View file

@ -53,8 +53,7 @@ export async function getDashboardSummary(
export async function getExpensesByCategory( export async function getExpensesByCategory(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string
sourceId?: number,
): Promise<CategoryBreakdownItem[]> { ): Promise<CategoryBreakdownItem[]> {
const db = await getDb(); const db = await getDb();
@ -72,11 +71,6 @@ export async function getExpensesByCategory(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) {
whereClauses.push(`t.source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
}
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`; const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;

View file

@ -12,8 +12,7 @@ import type {
export async function getMonthlyTrends( export async function getMonthlyTrends(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string
sourceId?: number,
): Promise<MonthlyTrendItem[]> { ): Promise<MonthlyTrendItem[]> {
const db = await getDb(); const db = await getDb();
@ -31,11 +30,6 @@ export async function getMonthlyTrends(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) {
whereClauses.push(`source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
}
const whereSQL = const whereSQL =
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
@ -56,8 +50,7 @@ export async function getMonthlyTrends(
export async function getCategoryOverTime( export async function getCategoryOverTime(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
topN: number = 50, topN: number = 8
sourceId?: number,
): Promise<CategoryOverTimeData> { ): Promise<CategoryOverTimeData> {
const db = await getDb(); const db = await getDb();
@ -75,11 +68,6 @@ export async function getCategoryOverTime(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) {
whereClauses.push(`t.source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
}
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`; const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;

View file

@ -139,10 +139,9 @@ export interface BudgetYearRow {
category_type: "expense" | "income" | "transfer"; category_type: "expense" | "income" | "transfer";
parent_id: number | null; parent_id: number | null;
is_parent: boolean; is_parent: boolean;
depth?: number; depth?: 0 | 1 | 2;
months: number[]; // index 0-11 = Jan-Dec planned amounts months: number[]; // index 0-11 = Jan-Dec planned amounts
annual: number; // computed sum annual: number; // computed sum
previousYearTotal: number; // actual (transactions) total from the previous year
} }
export interface ImportConfigTemplate { export interface ImportConfigTemplate {
@ -332,7 +331,7 @@ export interface BudgetVsActualRow {
category_type: "expense" | "income" | "transfer"; category_type: "expense" | "income" | "transfer";
parent_id: number | null; parent_id: number | null;
is_parent: boolean; is_parent: boolean;
depth?: number; depth?: 0 | 1 | 2;
monthActual: number; monthActual: number;
monthBudget: number; monthBudget: number;
monthVariation: number; monthVariation: number;

View file

@ -1,123 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { computeDateRange, buildMonthOptions } from "./dateRange";
describe("computeDateRange", () => {
beforeEach(() => {
// Fix "now" to 2025-07-15 for deterministic tests
vi.useFakeTimers();
vi.setSystemTime(new Date(2025, 6, 15)); // July 15, 2025
});
afterEach(() => {
vi.useRealTimers();
});
it('returns empty object for "all" period', () => {
expect(computeDateRange("all")).toEqual({});
});
it('returns custom range for "custom" period', () => {
expect(computeDateRange("custom", "2025-01-01", "2025-06-30")).toEqual({
dateFrom: "2025-01-01",
dateTo: "2025-06-30",
});
});
it('falls back to default when "custom" has missing dates', () => {
const result = computeDateRange("custom");
// Should fall through to default (same as "month")
expect(result.dateFrom).toBe("2025-07-01");
expect(result.dateTo).toBe("2025-07-15");
});
it('computes "month" period (first of current month to today)', () => {
const result = computeDateRange("month");
expect(result).toEqual({ dateFrom: "2025-07-01", dateTo: "2025-07-15" });
});
it('computes "3months" period (3 months back)', () => {
const result = computeDateRange("3months");
expect(result).toEqual({ dateFrom: "2025-05-01", dateTo: "2025-07-15" });
});
it('computes "6months" period (6 months back)', () => {
const result = computeDateRange("6months");
expect(result).toEqual({ dateFrom: "2025-02-01", dateTo: "2025-07-15" });
});
it('computes "year" period (Jan 1st of current year)', () => {
const result = computeDateRange("year");
expect(result).toEqual({ dateFrom: "2025-01-01", dateTo: "2025-07-15" });
});
it('computes "12months" period (12 months back)', () => {
const result = computeDateRange("12months");
expect(result).toEqual({ dateFrom: "2024-08-01", dateTo: "2025-07-15" });
});
it("handles January rollover for 3months period", () => {
vi.setSystemTime(new Date(2025, 1, 10)); // Feb 10, 2025
const result = computeDateRange("3months");
expect(result).toEqual({ dateFrom: "2024-12-01", dateTo: "2025-02-10" });
});
it("handles January rollover for 6months period", () => {
vi.setSystemTime(new Date(2025, 0, 20)); // Jan 20, 2025
const result = computeDateRange("6months");
expect(result).toEqual({ dateFrom: "2024-08-01", dateTo: "2025-01-20" });
});
it("handles January rollover for 12months period", () => {
vi.setSystemTime(new Date(2025, 0, 5)); // Jan 5, 2025
const result = computeDateRange("12months");
expect(result).toEqual({ dateFrom: "2024-02-01", dateTo: "2025-01-05" });
});
});
describe("buildMonthOptions", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2025, 6, 15)); // July 15, 2025
});
afterEach(() => {
vi.useRealTimers();
});
it("returns 24 month options", () => {
const options = buildMonthOptions("fr");
expect(options).toHaveLength(24);
});
it("starts with the current month", () => {
const options = buildMonthOptions("en");
expect(options[0].key).toBe("2025-7");
expect(options[0].value).toBe("2025-7");
});
it("ends 23 months ago", () => {
const options = buildMonthOptions("en");
expect(options[23].key).toBe("2023-8");
expect(options[23].value).toBe("2023-8");
});
it("handles January rollover correctly", () => {
vi.setSystemTime(new Date(2025, 0, 15)); // Jan 15, 2025
const options = buildMonthOptions("en");
expect(options[0].key).toBe("2025-1");
expect(options[1].key).toBe("2024-12");
expect(options[12].key).toBe("2024-1");
});
it("capitalizes the first letter of labels", () => {
const options = buildMonthOptions("fr");
for (const opt of options) {
expect(opt.label[0]).toBe(opt.label[0].toUpperCase());
}
});
it("labels contain year information", () => {
const options = buildMonthOptions("en");
expect(options[0].label).toContain("2025");
});
});

View file

@ -1,66 +0,0 @@
import type { DashboardPeriod } from "../shared/types";
/**
* Compute a date range (dateFrom / dateTo) based on the selected period.
* Shared between useDashboard, useReports, DashboardPage and ReportsPage.
*/
export function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "year":
from = new Date(year, 0, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
default:
from = new Date(year, month, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
/**
* Build an array of month options for the budget month dropdown.
* Returns the last 24 months with localized labels.
*/
export function buildMonthOptions(language: string): Array<{ key: string; value: string; label: string }> {
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
return Array.from({ length: 24 }, (_, i) => {
const d = new Date(currentYear, currentMonth - i, 1);
const y = d.getFullYear();
const m = d.getMonth() + 1;
const label = new Intl.DateTimeFormat(language, { month: "long", year: "numeric" }).format(d);
return { key: `${y}-${m}`, value: `${y}-${m}`, label: label.charAt(0).toUpperCase() + label.slice(1) };
});
}

View file

@ -1,38 +0,0 @@
/**
* Shared utility for reordering budget table rows.
* Recursively moves subtotal (parent) rows below their children
* at every depth level when "subtotals on bottom" is enabled.
*/
export function reorderRows<
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number },
>(rows: T[], subtotalsOnTop: boolean): T[] {
if (subtotalsOnTop) return rows;
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
const result: T[] = [];
let currentParent: T | null = null;
let currentChildren: T[] = [];
for (const row of groupRows) {
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
// Flush previous group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
currentChildren = [];
}
currentParent = row;
} else if (currentParent) {
currentChildren.push(row);
} else {
result.push(row);
}
}
// Flush last group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
}
return result;
}
return reorderGroup(rows, 0);
}

View file

@ -1,53 +1,33 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { copyFileSync } from "fs";
import { resolve } from "path";
// Sync root CHANGELOG files to public/ so the app always shows the latest version history
function syncChangelogs() {
const root = import.meta.dirname;
const files = ["CHANGELOG.md", "CHANGELOG.fr.md"];
for (const file of files) {
try {
copyFileSync(resolve(root, file), resolve(root, "public", file));
} catch {
// Ignore if source file doesn't exist
}
}
}
// @ts-expect-error process is a nodejs global // @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(async () => { export default defineConfig(async () => ({
// Sync changelogs before starting dev server or building plugins: [react(), tailwindcss()],
syncChangelogs();
return { // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
plugins: [react(), tailwindcss()], //
// 1. prevent Vite from obscuring rust errors
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` clearScreen: false,
// // 2. tauri expects a fixed port, fail if that port is not available
// 1. prevent Vite from obscuring rust errors server: {
clearScreen: false, port: 1420,
// 2. tauri expects a fixed port, fail if that port is not available strictPort: true,
server: { host: host || false,
port: 1420, hmr: host
strictPort: true, ? {
host: host || false, protocol: "ws",
hmr: host host,
? { port: 1421,
protocol: "ws", }
host, : undefined,
port: 1421, watch: {
} // 3. tell Vite to ignore watching `src-tauri`
: undefined, ignored: ["**/src-tauri/**"],
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
}, },
}; },
}); }));