Compare commits

..

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

36 changed files with 917 additions and 1173 deletions

4
.gitignore vendored
View file

@ -45,9 +45,5 @@ imports/*.csv
.DS_Store
Thumbs.db
# Auto-generated changelogs (synced from root by vite.config.ts)
public/CHANGELOG.md
public/CHANGELOG.fr.md
# Tauri generated
src-tauri/gen/

View file

@ -2,65 +2,6 @@
## [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é

View file

@ -2,65 +2,6 @@
## [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

View file

@ -9,7 +9,7 @@
**Stockage :** SQLite local (tauri-plugin-sql)
**Langues supportées :** Français (FR) et Anglais (EN)
**Plateformes :** Windows, Linux
**Version actuelle :** 0.6.3
**Version actuelle :** 0.5.0
**Licence :** GPL-3.0-only
---
@ -50,7 +50,7 @@ src/
│ └── transactions/ # Transactions
├── contexts/ # ProfileContext (état global profil)
├── hooks/ # 12 hooks custom (useReducer)
├── pages/ # 11 pages
├── pages/ # 10 pages
├── services/ # 14 services métier
├── shared/ # Types et constantes partagés
├── 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
- **Export/Import** : JSON/CSV avec chiffrement AES-256-GCM optionnel (format SREF)
- **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/`)
---
@ -141,7 +140,7 @@ La documentation technique est centralisée dans `docs/` :
- `CHANGELOG.fr.md` (français) — traduction
- 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.
- Les deux fichiers sont copiés dans `public/` par le CI (`release.yml`). En dev, synchroniser manuellement : `cp CHANGELOG*.md public/`
---

View file

@ -1,6 +1,6 @@
# 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
@ -30,11 +30,11 @@ simpl-resultat/
│ │ ├── adjustments/ # 3 composants
│ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 2 composants
│ │ ├── dashboard/ # 3 composants
│ │ ├── import/ # 13 composants (wizard d'import)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
│ │ ├── shared/ # 6 composants réutilisables
│ │ └── 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 |
|-------|------|-------------|
| `/` | `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 |
| `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/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 |
| `/settings` | `SettingsPage` | Paramètres |
| `/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).

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
- Répartition des dépenses par catégorie (graphique circulaire avec motifs SVG)
- Tableau Budget vs Réel du mois courant (écart en $ et %)
- Histogramme empilé des dépenses par catégorie et par mois
- Sélecteur de période ajustable (par défaut : année à ce jour)
- Liste des transactions récentes
- Sélecteur de période ajustable
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
### 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.)
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
4. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
5. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
6. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
4. Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions
5. Faites défiler vers le bas pour voir vos transactions les plus récentes
### 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
- 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

344
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "simpl_result_scaffold",
"version": "0.6.5",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl_result_scaffold",
"version": "0.6.5",
"version": "0.5.0",
"license": "GPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -35,8 +35,7 @@
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.8.3",
"vite": "^6.4.1",
"vitest": "^4.0.18"
"vite": "^6.4.1"
}
},
"node_modules/@babel/code-frame": {
@ -1764,17 +1763,6 @@
"@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": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@ -1829,13 +1817,6 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1903,127 +1884,6 @@
"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": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@ -2086,16 +1946,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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2288,13 +2138,6 @@
"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": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
@ -2350,31 +2193,11 @@
"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": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"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": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2820,29 +2643,11 @@
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"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": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3115,13 +2920,6 @@
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3131,20 +2929,6 @@
"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": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@ -3169,23 +2953,6 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"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": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3202,16 +2969,6 @@
"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": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -3369,84 +3126,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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@ -3455,23 +3134,6 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

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

207
public/CHANGELOG.fr.md Normal file
View file

@ -0,0 +1,207 @@
# Journal des modifications
## [Non publié]
## [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

208
public/CHANGELOG.md Normal file
View file

@ -0,0 +1,208 @@
# Changelog
## [Unreleased]
## [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]
### Added
- Error boundary catches React crashes and displays an error page instead of a white screen
- Startup timeout (10s) on database connection — shows error page instead of infinite spinner
- Error page with "Refresh", "Check for updates", and contact/issue links
- Log viewer in settings page — captures console output, filterable by level, copyable, persists across refresh
- GPL-3.0 license — project is now open source
### Changed
- Report detail modal: sortable columns — click headers to sort by date, description, or amount (#1)
- Report detail modal: toggle to show/hide amounts column (#3)
- Budget table: column headers stay fixed when scrolling vertically (#2)
### Fixed
- Auto-updater on Linux: `latest.json` version field no longer has `v` prefix, package registry upload is more robust
- Startup retry: DB connection retries up to 3 times before showing error page (fixes first-launch failure on Windows)
- Migration checksum mismatch: automatically repairs stale migration 1 checksum on startup
## [0.4.4]
### Fixed
- Linux binary now compatible with glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — CI builds in Ubuntu 22.04 container
## [0.4.3]
### Fixed
- Auto-updater endpoint now uses Forgejo package registry for stable URL
- Linux updater signatures (.AppImage.sig) now correctly collected in CI
- All platform signatures (.deb.sig, .rpm.sig) now included in release assets
## [0.4.2]
### Changed
- Auto-updater now points to self-hosted Forgejo instance
- Windows builds now cross-compiled via cargo-xwin
## [0.4.1]
### Fixed
- App stuck on infinite spinner after updating from v0.3.x (migration checksum mismatch on seed_categories.sql)
- DB connection errors now logged to console instead of silently failing
## [0.4.0]
### Added
- Categories: support for 3 levels of hierarchy (e.g., Dépenses récurrentes → Assurances → Assurance-auto)
- Dynamic Report: new "Category (Level 3)" pivot field
- Budget: intermediate subtotals and 3-level indentation for nested categories
- Categories: automatic `is_inputable` management when creating/deleting subcategories
- Categories: depth validation prevents creating a 4th level
- Seed data: Assurances split into Assurance-auto, Assurance-habitation, Assurance-vie
### Fixed
- Auto-categorization: keywords starting/ending with special characters (`[`, `]`, `(`, `)`, `-`, etc.) now match correctly
- Auto-categorization: pre-compile regex patterns for better batch performance
## [0.3.11]
### Added
- Dynamic Report: support multiple column dimensions (composite column keys)
### Fixed
- Dynamic Report: no longer affected by global page date filters — uses only its own panel filters
## [0.3.10]
### Added
- Dynamic Report: fields can now be used in multiple zones simultaneously (rows + filters, columns + filters)
- Dynamic Report: right-click on a filter value to exclude it (shown with strikethrough in red)
- "This year" period option in reports and dashboard (Jan 1 to today)
## [0.3.9]
### Added
- Dynamic Report (pivot table): compose custom reports by assigning dimensions to rows, columns, filters and measures to values
- Delete keywords from the "All Keywords" view
## [0.3.8]
### Added
- Custom date range picker for reports and dashboard
- Toggle to position subtotals above or below detail rows
- Display release notes from CHANGELOG in GitHub releases and in-app updater
## [0.3.7]
### Fixes
- Remove MSI bundle to prevent updater install path conflict
- Change Windows updater installMode to basicUi
- Improve split indicator visibility and adjustments layout
## 0.3.2
### New Features
- **Linux support**: Add Linux build (`.deb`, `.rpm`, `.AppImage`) to release workflow
- **Transaction splits on Adjustments page**: View transaction split adjustments in a dedicated section on the Adjustments page
### Fixes
- Fix CSV auto-detect edge cases
- Remove accent from productName for Linux `.deb` compatibility
## 0.3.1
### Fixes
- Always show profile switcher in sidebar (#2)
## 0.3.0
### New Features
- **Multiple profiles**: Create multiple profiles with separate databases, custom names, and colors
- **PIN protection**: Protect profiles with an optional numeric PIN
- **Profile switcher**: Quick profile switching from the sidebar
- **Drag-and-drop categories**: Reorder categories or change parent via drag-and-drop in the category tree
- **Transaction splits**: Split a transaction across multiple categories with adjustable amounts
## 0.2.10
### New Features
- **Period quick-select**: Add quick period filter buttons (This month, Last month, etc.) on the Transactions page
- **Budget vs Actual report**: Monthly and year-to-date comparison table in Reports
- **Parent category subtotals**: Budget page shows aggregated subtotals for parent categories
- **User guide**: Complete documentation page accessible from Settings, printable to PDF
### Improvements
- Persist template selection and add Update template button
- Don't pre-select already-imported files when entering source config
- Make settings data imports visible in Import History
- Replace per-template delete buttons with single delete on selection
- Replace refresh icon with save icon on update template button
- Add sign convention to budget page
## 0.2.9
### Fixes
- Allow duplicate-content files with different names (#1)
## 0.2.8
### New Features
- **Data export/import**: Export and import your data (transactions, categories, or both) with optional AES-256-GCM encryption (#3)
### Fixes
- Cross-file duplicate detection and per-file import tracking
## 0.2.5
### New Features
- **Import config templates**: Save and load import source configurations as reusable templates
- **12-month budget grid**: Full year budget view with monthly cells and annual totals
### Fixes
- Budget and category fixes
- Migration checksum issue (schema.sql must not be modified after initial release)
## 0.2.3
### New Features
- **Chart patterns**: Added SVG fill patterns (diagonal lines, dots, crosshatch, etc.) to differentiate categories in bar charts, pie chart, and stacked bar charts beyond just color
- **Chart context menu**: Right-click any category in a chart to hide it or view its transactions in a detail popup
- **Hidden categories**: Hidden categories appear as dismissible chips above charts with a "Show all" button to restore them
- **Transaction detail modal**: View all transactions composing a category's total directly from any chart
- **Import preview popup**: The data preview is now a popup modal instead of a separate wizard step, allowing quick inspection without leaving the configuration page
- **Direct duplicate check**: New "Check Duplicates" button on the import configuration page skips directly to duplicate validation without requiring a preview first
### Improvements
- Import wizard flow simplified: source-config → duplicate-check (preview is optional via popup)
- Duplicate-check back button now returns to source configuration instead of the removed preview step
- Added `categoryIds` map to `CategoryOverTimeData` for proper category resolution in the over-time chart
## 0.2.2
- Bump version
## 0.2.1
- Add "All Keywords" view on Categories page
- Add dark mode with warm gray palette
- Fix orphan categories, persist has_header for imports, add re-initialize
- Add Budget and Adjustments pages

View file

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

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat",
"version": "0.6.6",
"version": "0.6.1",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -2,7 +2,6 @@ import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const fmt = new Intl.NumberFormat("en-CA", {
style: "currency",
@ -19,6 +18,58 @@ const MONTH_KEYS = [
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 {
rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
@ -133,16 +184,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
income: "budget.income",
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)
const monthTotals: number[] = Array(12).fill(0);
let annualTotal = 0;
let prevYearTotal = 0;
for (const row of rows) {
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
const sign = signFor(row.category_type);
@ -150,10 +195,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
monthTotals[m] += row.months[m] * 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) {
return (
@ -179,15 +223,13 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
if (row.is_parent) {
// Parent subtotal row: read-only, bold, distinct background
const parentDepth = row.depth ?? 0;
const isTopParent = parentDepth === 0;
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";
const isIntermediateParent = parentDepth === 1;
return (
<tr
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">
<span
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>
</div>
</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"}`}>
{formatSigned(row.annual * sign)}
</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"
>
{/* 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">
<span
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>
</div>
</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 */}
<td className="py-2 px-2 text-right">
{editingAnnual?.categoryId === row.category_id ? (
@ -313,9 +346,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]">
{t("budget.category")}
</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]">
{t("budget.annual")}
</th>
@ -330,18 +360,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{typeOrder.map((type) => {
const group = grouped[type];
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 (
<Fragment key={type}>
<tr>
@ -353,28 +371,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</td>
</tr>
{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>
);
})}
{/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
<tr className="bg-[var(--muted)] font-semibold">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
<td className="py-2.5 px-2 text-right text-xs">{formatSigned(annualTotal)}</td>
{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)}
</td>
))}

View file

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

View file

@ -2,7 +2,6 @@ import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", {
@ -26,6 +25,55 @@ interface BudgetVsActualTableProps {
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) {
const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
@ -57,11 +105,6 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
income: t("budget.income"),
transfer: t("budget.transfers"),
};
const typeTotalKeys: Record<SectionType, string> = {
expense: "budget.totalExpenses",
income: "budget.totalIncome",
transfer: "budget.totalTransfers",
};
let currentType: SectionType | null = null;
for (const row of data) {
@ -103,7 +146,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<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 bg-[var(--card)]">
{t("budget.category")}
</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)]">
@ -141,49 +184,27 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr>
</thead>
<tbody>
{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 (
{sections.map((section) => (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<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)]">
<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">
{section.label}
</td>
</tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
const isIntermediateParent = isParent && depth === 1;
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" :
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
}`}
>
<td className={`py-1.5 sticky left-0 z-10 ${
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)]`
}`}>
<td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}>
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
@ -215,53 +236,29 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</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>
);
})}
))}
{/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<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="text-right px-3 py-3 border-l border-[var(--border)]/50">
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
<td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
{cadFormatter(totals.monthVariation)}
</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)}
</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)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
<td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
{cadFormatter(totals.ytdVariation)}
</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)}
</td>
</tr>

View file

@ -97,7 +97,7 @@ export default function CategoryBarChart({
type="category"
dataKey="category_name"
width={120}
tick={{ fill: "var(--foreground)", fontSize: 12 }}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<Tooltip
@ -107,9 +107,7 @@ export default function CategoryBarChart({
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>

View file

@ -123,9 +123,7 @@ export default function CategoryOverTimeChart({
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
filterNull
@ -136,7 +134,6 @@ export default function CategoryOverTimeChart({
}}
onMouseLeave={() => setHoveredCategory(null)}
wrapperStyle={{ cursor: "pointer" }}
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
/>
{categoryEntries.map((c) => (
<Bar

View file

@ -80,8 +80,8 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
</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>
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2 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(
@ -89,12 +89,12 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
0,
);
return (
<td key={month} className="text-right px-3 py-3">
<td key={month} className="text-right px-3 py-2">
{cadFormatter(monthTotal)}
</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(
visibleCategories.reduce(
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),

View file

@ -61,10 +61,10 @@ export default function CategoryTable({ data, hiddenCategories }: CategoryTableP
</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 className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-2">{cadFormatter(grandTotal)}</td>
<td className="text-right px-3 py-2 text-[var(--muted-foreground)]">100%</td>
</tr>
</tbody>
</table>

View file

@ -192,13 +192,13 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
))
)}
{/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-3">
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-2">
{t("reports.pivot.total")}
</td>
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))

View file

@ -61,11 +61,11 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
</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)]"}`}>
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-2 text-[var(--positive)]">{cadFormatter(totals.income)}</td>
<td className="text-right px-3 py-2 text-[var(--negative)]">{cadFormatter(totals.expenses)}</td>
<td className={`text-right px-3 py-2 ${totals.income - totals.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
{cadFormatter(totals.income - totals.expenses)}
</td>
</tr>

View file

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

View file

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

View file

@ -12,7 +12,6 @@ import type {
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
interface ReportsState {
tab: ReportTab;
@ -60,8 +59,8 @@ const initialState: ReportsState = {
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
budgetYear: now.getFullYear(),
budgetMonth: now.getMonth() + 1,
budgetVsActual: [],
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
@ -102,6 +101,50 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
}
}
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() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
@ -181,9 +224,18 @@ export function useReports() {
dispatch({ type: "SET_PERIOD", payload: period });
}, []);
const setBudgetMonth = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
}, []);
const navigateBudgetMonth = useCallback((delta: -1 | 1) => {
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) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
@ -197,5 +249,5 @@ export function useReports() {
dispatch({ type: "SET_SOURCE_ID", payload: id });
}, []);
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId };
}

View file

@ -26,8 +26,6 @@
"noData": "No data available. Start by importing your bank statements.",
"expensesByCategory": "Expenses by Category",
"recentTransactions": "Recent Transactions",
"budgetVsActual": "Budget vs Actual",
"expensesOverTime": "Expenses Over Time",
"period": {
"month": "This month",
"3months": "3 months",
@ -319,7 +317,6 @@
"actual": "Actual",
"difference": "Difference",
"annual": "Annual",
"previousYear": "Prev. Year",
"splitEvenly": "Split evenly across 12 months",
"annualMismatch": "Annual total does not match the sum of monthly amounts",
"clickToEdit": "Click to edit",
@ -328,9 +325,6 @@
"expenses": "Expenses",
"income": "Income",
"transfers": "Transfers",
"totalExpenses": "Total Expenses",
"totalIncome": "Total Income",
"totalTransfers": "Total Transfers",
"totalPlanned": "Total Planned",
"totalActual": "Total Actual",
"totalDifference": "Difference",
@ -377,8 +371,7 @@
"ytd": "Year-to-Date",
"dollarVar": "$ Var",
"pctVar": "% Var",
"noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for"
"noData": "No budget or transaction data for this period."
},
"dynamic": "Dynamic Report",
"export": "Export",
@ -590,21 +583,18 @@
"features": [
"Balance, income, and expense summary cards",
"Expense breakdown by category (pie chart with SVG patterns)",
"Budget vs Actual table for the current month (variance in $ and %)",
"Stacked bar chart of expenses by category and month",
"Adjustable time period selector (default: year to date)",
"Recent transactions list",
"Adjustable time period selector",
"Context menu (right-click) to hide a category or view its transactions"
],
"steps": [
"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",
"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",
"Analyze the stacked bar chart at the bottom to see expense trends by category over time",
"Right-click a category in a chart to hide it or view its transaction details"
"Right-click a category in the chart to hide it or view its transaction details",
"Scroll down to see your most recent transactions"
],
"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",
"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"

View file

@ -26,8 +26,6 @@
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
"expensesByCategory": "Dépenses par catégorie",
"recentTransactions": "Transactions récentes",
"budgetVsActual": "Budget vs Réel",
"expensesOverTime": "Dépenses dans le temps",
"period": {
"month": "Ce mois",
"3months": "3 mois",
@ -319,7 +317,6 @@
"actual": "Réel",
"difference": "Écart",
"annual": "Annuel",
"previousYear": "Année préc.",
"splitEvenly": "Répartir également sur 12 mois",
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
"clickToEdit": "Cliquer pour modifier",
@ -328,9 +325,6 @@
"expenses": "Dépenses",
"income": "Revenus",
"transfers": "Transferts",
"totalExpenses": "Total des dépenses",
"totalIncome": "Total des revenus",
"totalTransfers": "Total des transferts",
"totalPlanned": "Total prévu",
"totalActual": "Total réel",
"totalDifference": "Écart",
@ -377,8 +371,7 @@
"ytd": "Cumul annuel",
"dollarVar": "$ \u00c9cart",
"pctVar": "% \u00c9cart",
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
"titlePrefix": "Budget vs Réel pour le mois de"
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
},
"dynamic": "Rapport dynamique",
"export": "Exporter",
@ -590,21 +583,18 @@
"features": [
"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)",
"Tableau Budget vs Réel du mois courant (écart en $ et %)",
"Histogramme empilé des dépenses par catégorie et par mois",
"Sélecteur de période ajustable (par défaut : année à ce jour)",
"Liste des transactions récentes",
"Sélecteur de période ajustable",
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions"
],
"steps": [
"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",
"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",
"Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps",
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions"
"Cliquez droit sur une catégorie dans le graphique pour la masquer ou voir le détail de ses transactions",
"Faites défiler vers le bas pour voir vos transactions les plus récentes"
],
"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",
"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"

View file

@ -90,7 +90,7 @@ export default function CategoriesPage() {
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">
<CategoryTree
tree={state.tree}

View file

@ -1,22 +1,47 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { useDashboard } from "../hooks/useDashboard";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem } from "../shared/types";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
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() {
const { t, i18n } = useTranslation();
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
const { t } = useTranslation();
const { state, setPeriod, setCustomDates } = useDashboard();
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
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);
return (
@ -85,7 +108,7 @@ export default function DashboardPage() {
/>
</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) => (
<div
key={card.labelKey}
@ -102,48 +125,15 @@ export default function DashboardPage() {
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
<div className="lg:col-span-1">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<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}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<CategoryPieChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
<RecentTransactionsList transactions={recentTransactions} />
</div>
{detailModal && (

View file

@ -3,9 +3,10 @@ import { useTranslation } from "react-i18next";
import { Hash, Table, BarChart3 } from "lucide-react";
import { useReports } from "../hooks/useReports";
import { PageHelp } from "../components/shared/PageHelp";
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod, ImportSource } from "../shared/types";
import { getAllSources } from "../services/importSourceService";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthNavigator from "../components/budget/MonthNavigator";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryBarChart from "../components/reports/CategoryBarChart";
@ -16,13 +17,39 @@ import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import DynamicReport from "../components/reports/DynamicReport";
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
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 ReportsPage() {
const { t, i18n } = useTranslation();
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
const { t } = useTranslation();
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId } = useReports();
const [sources, setSources] = useState<ImportSource[]>([]);
useEffect(() => {
@ -66,8 +93,6 @@ export default function ReportsPage() {
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);
@ -75,30 +100,16 @@ export default function ReportsPage() {
<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="flex items-center gap-3">
{state.tab === "budgetVsActual" ? (
<h1 className="text-2xl font-bold flex items-center gap-2 flex-wrap">
{t("reports.bva.titlePrefix")}
<select
value={`${state.budgetYear}-${state.budgetMonth}`}
onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number);
setBudgetMonth(y, m);
}}
className="text-lg font-bold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
>
{monthOptions.map((opt) => (
<option key={opt.key} value={opt.value}>
{opt.label}
</option>
))}
</select>
</h1>
) : (
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
)}
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
<PageHelp helpKey="reports" />
</div>
{state.tab !== "budgetVsActual" && (
{state.tab === "budgetVsActual" ? (
<MonthNavigator
year={state.budgetYear}
month={state.budgetMonth}
onNavigate={navigateBudgetMonth}
/>
) : (
<PeriodSelector
value={state.period}
onChange={setPeriod}

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]);
}
// --- 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 ---
async function getActualsByCategoryRange(
@ -241,6 +231,7 @@ export async function getBudgetVsActualData(
}
// Index categories
const catById = new Map(allCategories.map((c) => [c.id, c]));
const childrenByParent = new Map<number, Category[]>();
for (const cat of allCategories) {
if (cat.parent_id) {
@ -253,7 +244,7 @@ export async function getBudgetVsActualData(
const signFor = (type: string) => (type === "expense" ? -1 : 1);
// 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 monthMap = entryMap.get(cat.id);
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 = {
category_id: cat.id,
category_name: cat.name,
@ -332,41 +323,35 @@ export async function getBudgetVsActualData(
);
}
// Build rows for a sub-group (recursive, supports arbitrary depth)
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
const subChildren = childrenByParent.get(cat.id) || [];
const hasSubChildren = subChildren.some(
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
);
if (!hasSubChildren && cat.is_inputable) {
const leaf = buildLeaf(cat, groupParentId, depth);
// Build rows for a level-2 parent (intermediate parent with grandchildren)
function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at level 2
const leaf = buildLeaf(cat, grandparentId, 2);
return isRowAllZero(leaf) ? [] : [leaf];
}
if (!hasSubChildren) return [];
if (grandchildren.length === 0) return [];
const childRows: BudgetVsActualRow[] = [];
const gcRows: BudgetVsActualRow[] = [];
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)`;
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 child of sortedSubChildren) {
const grandchildren = childrenByParent.get(child.id) || [];
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);
}
for (const gc of grandchildren) {
const leaf = buildLeaf(gc, cat.id, 2);
if (!isRowAllZero(leaf)) gcRows.push(leaf);
}
if (childRows.length === 0) return [];
if (gcRows.length === 0) return [];
const leafRows = childRows.filter((r) => !r.is_parent);
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
return [subtotal, ...childRows];
const subtotal = buildSubtotal(cat, gcRows, grandparentId, 1);
gcRows.sort((a, b) => {
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[] = [];
@ -374,15 +359,15 @@ export async function getBudgetVsActualData(
for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || [];
const hasChildren = children.some(
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
);
const inputableChildren = children.filter((c) => c.is_inputable);
// 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
const leaf = buildLeaf(cat, null, 0);
if (!isRowAllZero(leaf)) rows.push(leaf);
} else if (hasChildren) {
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetVsActualRow[] = [];
// Direct transactions on the parent itself
@ -392,19 +377,25 @@ export async function getBudgetVsActualData(
if (!isRowAllZero(direct)) allChildRows.push(direct);
}
// Process children in alphabetical order
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
for (const child of sortedChildren) {
// Level-2 leaves (direct children that are inputable and have no children)
for (const child of inputableChildren) {
const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length > 0) {
const subRows = buildSubGroup(child, cat.id, 1);
allChildRows.push(...subRows);
} else if (child.is_inputable) {
if (grandchildren.length === 0) {
const leaf = buildLeaf(child, cat.id, 1);
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;
// 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);
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);
}
}
// Sort by type only, preserving tree order within groups (already built correctly)
const rowOrder = new Map<BudgetVsActualRow, number>();
rows.forEach((r, i) => rowOrder.set(r, i));
// Sort by type, then within same type keep parent+children groups together
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9;
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;

View file

@ -56,7 +56,7 @@ export async function getMonthlyTrends(
export async function getCategoryOverTime(
dateFrom?: string,
dateTo?: string,
topN: number = 50,
topN: number = 8,
sourceId?: number,
): Promise<CategoryOverTimeData> {
const db = await getDb();

View file

@ -139,10 +139,9 @@ export interface BudgetYearRow {
category_type: "expense" | "income" | "transfer";
parent_id: number | null;
is_parent: boolean;
depth?: number;
depth?: 0 | 1 | 2;
months: number[]; // index 0-11 = Jan-Dec planned amounts
annual: number; // computed sum
previousYearTotal: number; // actual (transactions) total from the previous year
}
export interface ImportConfigTemplate {
@ -332,7 +331,7 @@ export interface BudgetVsActualRow {
category_type: "expense" | "income" | "transfer";
parent_id: number | null;
is_parent: boolean;
depth?: number;
depth?: 0 | 1 | 2;
monthActual: number;
monthBudget: 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 react from "@vitejs/plugin-react";
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
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => {
// Sync changelogs before starting dev server or building
syncChangelogs();
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
return {
plugins: [react(), tailwindcss()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
};
});
},
}));