Compare commits

...

35 commits

Author SHA1 Message Date
99fdf4f9ea Merge pull request 'refactor: inline buildPrevYearTotalMap and simplify tests (#39)' (#40) from fix/simpl-resultat-39-simplify-budget into main
All checks were successful
Release / build-and-release (push) Successful in 28m8s
2026-03-11 21:34:35 +00:00
le king fu
003f456203 chore: bump version to 0.6.6
Some checks failed
Release / build-and-release (push) Has been cancelled
Includes fixes #34, #37, #39: budget prev year actuals, changelog sync via Vite, inline buildPrevYearTotalMap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:29:04 -04:00
61a5b2e40d Merge pull request 'fix: sync changelog to public/ and automate on build/dev (#37)' (#38) from fix/simpl-resultat-37-changelog into main 2026-03-11 21:26:06 +00:00
c7caab0ef4 fix: address reviewer feedback (#37)
- Restore Unreleased entries in root CHANGELOG files (were incorrectly removed)
- Add public/CHANGELOG*.md to .gitignore (auto-generated by syncChangelogs)
- Remove public/CHANGELOG*.md from git tracking
- Fix indentation in vite.config.ts return block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:04:34 -04:00
03c5f2538f refactor: inline buildPrevYearTotalMap and remove disproportionate tests (#39)
The 3-line helper was exported solely for testing. Inlining it removes the
export-for-test pattern and eliminates 50 lines of tests that were
disproportionate for a trivial filter-and-set loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:02:47 -04:00
5493e0c4e2 fix: address reviewer feedback (#37)
- Revert public/ changelog changes (syncChangelogs copies from root)
- Replace manual __dirname with import.meta.dirname

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:01:51 -04:00
ecbcf44f86 Merge pull request 'fix: show actual transactions in budget previous year column (#34)' (#35) from fix/simpl-resultat-34-budget-previous-year-actual into main 2026-03-11 16:16:19 +00:00
9afe23180f fix: remove redundant prebuild script (#37)
syncChangelogs() in vite.config.ts already handles copying changelogs
to public/ on dev/build start. The prebuild npm script was redundant
and introduced a platform dependency (cp command).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:03:48 -04:00
21bf1173ea fix: remove double sign negation for previous year actuals (#34)
Transaction amounts are already signed in the DB (expenses negative,
income positive). Remove Math.abs() normalization and stop multiplying
by sign at display time to avoid double negation.

Extract buildPrevYearTotalMap as a testable exported function and
rewrite tests to import the real function instead of reimplementing it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:02:58 -04:00
a764ae0d38 fix: address reviewer feedback (#34)
Fix sign bug in previous year actuals column: transaction amounts are
stored with sign in the DB (expenses negative) but budget entries are
always positive. Apply Math.abs() when building the previousYearTotal
map so the display-time sign multiplier works correctly.

Add unit tests for the normalization logic verifying that both expense
(negative in DB) and income (positive in DB) amounts are correctly
handled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:05:34 -04:00
fd88ba41ba Merge branch 'main' into fix/simpl-resultat-34-budget-previous-year-actual 2026-03-11 08:05:24 -04:00
4b6b4d96ef fix: sync public/ changelogs and automate copy on build/dev (#37)
The in-app changelog page reads from public/CHANGELOG*.md which were
stale (last synced at 0.6.3). Add automatic sync via Vite config
(runs on dev/build start) and npm prebuild script so public/ copies
are always up to date without manual intervention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:03:08 -04:00
501051f9ed Merge pull request 'test: add unit tests for dateRange.ts (#33)' (#36) from fix/simpl-resultat-33-daterange-tests into main 2026-03-11 11:12:25 +00:00
2a18d9be2d test: add unit tests for dateRange.ts utility
Add vitest and 17 tests covering computeDateRange (all periods
including January rollover) and buildMonthOptions (length, order,
rollover, label formatting). Add test/test:watch npm scripts.

Ref #33

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:05:32 -04:00
4e70eee0a8 feat: show actual transactions in budget previous year column
Replace planned budget data with actual transaction totals for the
previous year column in the budget table. Add getActualTotalsForYear
helper to budgetService.

Ref #34

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:03:26 -04:00
52faa017f3 chore: release v0.6.5
All checks were successful
Release / build-and-release (push) Successful in 27m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:14:52 -04:00
f8b44ebb6e Merge pull request 'feat: add month dropdown to dashboard Budget vs Actual (#31)' (#32) from fix/simpl-resultat-31-dashboard-month-dropdown into main 2026-03-11 00:12:39 +00:00
65815ef2e0 fix: address reviewer feedback (#31)
- Remove non-null assertions on bYear/bMonth in useDashboard fetchData
  by making parameters required
- Extract shared computeDateRange and buildMonthOptions into
  src/utils/dateRange.ts to eliminate duplication across useDashboard,
  useReports, DashboardPage and ReportsPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:53:31 -04:00
7d770f8b66 feat: add month dropdown to dashboard Budget vs Actual section (#31)
- Add budgetYear/budgetMonth state to useDashboard hook with last
  completed month as default
- Add month dropdown selector in the dashboard BudgetVsActual title
- Reduce dropdown font size in both Reports and Dashboard pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:04:46 -04:00
376ca4b477 chore: release v0.6.4
All checks were successful
Release / build-and-release (push) Successful in 27m16s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:30:37 -04:00
7458e087e1 Merge pull request 'fix: sticky category column and month dropdown selector (#29)' (#30) from fix/simpl-resultat-29-budget-visual-adjustments into main 2026-03-10 01:29:27 +00:00
64b7d8d11b fix: remove duplicated px-3 class and improve readability (#29)
Clean up the sticky category cell className: remove the duplicated
px-3 for top-level parents and break the long ternary into readable
multi-line format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:28:46 -04:00
8971e443d8 fix: address reviewer feedback (#29)
- Remove dead code: navigateBudgetMonth function and its export from
  useReports.ts (replaced by setBudgetMonth, no longer imported anywhere)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:28:30 -04:00
0d324b89c4 fix: address reviewer feedback (#29)
- Replace semi-transparent backgrounds on sticky columns with opaque
  color-mix equivalents so scrolled content is fully hidden
- Add opaque background to section header sticky td
- Extract IIFE month options in ReportsPage into a useMemo
2026-03-09 21:28:30 -04:00
c5a3e0f696 feat: sticky category column, month dropdown selector, default to last completed month (#29)
- Add sticky left-0 positioning to all category cells in BudgetVsActualTable
- Replace MonthNavigator arrows with inline title + dropdown month selector
- Default budget month to previous completed month instead of current
- Add i18n keys for new title prefix (FR/EN)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:28:09 -04:00
9551399f5f Merge pull request 'fix: display level 4+ categories under their parent in dashboard (#23)' (#28) from fix/simpl-resultat-23-dashboard-category-ordering into main 2026-03-10 01:26:19 +00:00
18c7ef3ee9 fix: make pie chart legend always visible with hover percentages (#23)
Show category names permanently in compact form (text-xs) so they
are always discoverable. Percentages appear only on chart hover to
save space, matching the original request while keeping the legend
accessible without interaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:17:59 -04:00
66d0cd85ff fix: address reviewer feedback (#23)
- Extract reorderRows into shared utility (src/utils/reorderRows.ts) to
  deduplicate identical function in BudgetTable and BudgetVsActualTable
- Restore alphabetical sorting of children in budgetService.ts
- Fix styling for intermediate parent rows at depth 2+ (was only handling
  depth 0-1)
- Reduce pie chart size (height 220->180, radii reduced) and padding to
  give more space to the table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:02:24 -04:00
4a5b5fb5fe fix: address reviewer feedback (#23)
- Make reorderRows() recursive to support subtotals toggle at all depth
  levels (not just depth 0-1)
- Restore pie chart legend (show on hover only to save space)
- Give more horizontal space to budget table (3/4 grid vs 2/3)
2026-03-09 20:46:00 -04:00
dbe249783e fix: display level 4+ categories under their parent in dashboard budget table (#23)
- Replace flat alphabetical sort with tree-order traversal so child
  categories appear directly under their parent subtotal row
- Make category hierarchy recursive (supports arbitrary depth)
- Reduce pie chart width from 1/2 to 1/3 of the dashboard
- Show pie chart labels only on hover via tooltip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:46 -04:00
8742c25945 Merge pull request 'feat: add previous year total column to budget table (#16)' (#18) from fix/simpl-resultat-16-budget-previous-year into main 2026-03-08 16:39:43 +00:00
e32a14557f Merge pull request 'fix: improve chart tooltip visibility over legend labels (#19)' (#21) from fix/simpl-resultat-19-tooltip-transparency into main 2026-03-08 16:39:22 +00:00
a6a46dd7b6 Merge pull request 'fix: remove internal pie chart title for dashboard consistency (#17)' (#20) from fix/simpl-resultat-17-remove-piechart-title into main 2026-03-08 16:38:57 +00:00
4923880a6e Add box-shadow and z-index to chart tooltips for better visibility (#19)
Tooltip was blending with the legend labels at the bottom of the chart,
especially when showing all categories at once. Added shadow and z-index
to ensure the tooltip stands out from the background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:06:16 -04:00
097c16dc14 feat: add previous year total column to budget table (#16)
Add a read-only column showing the annual budget total from the
previous year for each category. This serves as a baseline reference
when planning the current year's budget.

- Add previousYearTotal field to BudgetYearRow type
- Fetch previous year budget entries in useBudget hook
- Display column between Category and Annual in BudgetTable
- Propagate totals through parent/subtotal/section/grand total rows
- Add i18n keys for FR ("Année préc.") and EN ("Prev. Year")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:04:10 -04:00
28 changed files with 941 additions and 905 deletions

4
.gitignore vendored
View file

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

View file

@ -2,6 +2,39 @@
## [Non publié] ## [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] ## [0.6.3]
### Ajouté ### Ajouté

View file

@ -2,6 +2,39 @@
## [Unreleased] ## [Unreleased]
## [0.6.6]
### Changed
- Budget table: previous year column now shows actual transactions instead of planned budget (#34)
- Refactored `buildPrevYearTotalMap` inline and simplified tests (#39)
### Fixed
- Changelog files synced automatically via Vite plugin, removed stale public/ copies (#37)
## [0.6.5]
### Added
- Dashboard: month dropdown selector for the Budget vs Actual section with last completed month as default (#31)
### Changed
- Reports & Dashboard: reduced font size of month dropdown for better visual balance (#31)
## [0.6.4]
### Added
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
### Fixed
- Dashboard: level 4+ categories now appear under their parent instead of at the bottom of the section (#23)
- Dashboard: category hierarchy now supports arbitrary nesting depth (#23)
### Changed
- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23)
- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23)
- Budget vs Actual: category column now stays fixed when scrolling horizontally (#29)
- Budget vs Actual: title changed to "Budget vs Réel pour le mois de [month]" with a dropdown month selector (#29)
- Budget vs Actual: default month is now the last completed month instead of current month (#29)
## [0.6.3] ## [0.6.3]
### Added ### Added

View file

@ -141,7 +141,7 @@ La documentation technique est centralisée dans `docs/` :
- `CHANGELOG.fr.md` (français) — traduction - `CHANGELOG.fr.md` (français) — traduction
- Catégories : Added/Ajouté, Changed/Modifié, Fixed/Corrigé, Removed/Supprimé - 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. - 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.
- Les deux fichiers sont copiés dans `public/` par le CI (`release.yml`). En dev, synchroniser manuellement : `cp CHANGELOG*.md public/` - The `public/` copies are synced automatically: Vite copies them on `dev`/`build` start via `syncChangelogs()` in `vite.config.ts`. No manual sync needed.
--- ---

344
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.5.0", "version": "0.6.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.5.0", "version": "0.6.5",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -35,7 +35,8 @@
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.4.1" "vite": "^6.4.1",
"vitest": "^4.0.18"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -1763,6 +1764,17 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@ -1817,6 +1829,13 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1884,6 +1903,127 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@ -1946,6 +2086,16 @@
} }
] ]
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2138,6 +2288,13 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.44.0", "version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
@ -2193,11 +2350,31 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2643,11 +2820,29 @@
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true "dev": true
}, },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/papaparse": { "node_modules/papaparse": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==" "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2920,6 +3115,13 @@
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2929,6 +3131,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@ -2953,6 +3169,23 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -2969,6 +3202,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -3126,6 +3369,84 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/void-elements": { "node_modules/void-elements": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@ -3134,6 +3455,23 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

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

View file

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

View file

@ -1,234 +0,0 @@
# Changelog
## [Unreleased]
## [0.6.3]
### Added
- Dashboard: expenses over time stacked bar chart by category and month (#15)
- Dashboard: budget vs actual table for current month with variance in $ and % (#15)
- Budget table and Budget vs Actual report: section subtotal formatting with increasing visual weight (#14)
### Changed
- Dashboard: default period changed from "month" to "year to date" (#15)
- Dashboard: removed recent transactions section (#15)
- All report tables: grand total rows now use larger font (text-sm), bold weight, and thicker top border for better visual hierarchy (#14)
### Fixed
- Category over time report: all categories now displayed (limit increased from 8 to 50) (#13)
- Category bar chart: Y-axis labels now use foreground color instead of muted gray (#13)
- Category over time chart: legend text now uses foreground color instead of inheriting category color (#13)
## [0.6.2]
### Added
- Budget table: section subtotals for expenses, income, and transfers displayed after each group (#11)
- Budget vs Actual report: section subtotals with actual, planned, variation ($) and variation (%) per type (#11)
### Fixed
- Category page: detail panel now stays visible when scrolling through a long category list (#12)
## [0.6.1]
### Added
- Changelog page: full version history accessible from Settings at any time
- Bilingual changelog: release notes displayed in the user's selected language (EN/FR)
### Fixed
- Chart label visibility: amount labels on stacked bar charts now use black text with white outline for better contrast (#8)
- Budget table: editable cells now show hover background, pointer cursor, and tooltip hint for clearer affordance (#9)
## [0.6.0]
### Added
- Reports: toggle between table and chart view for Trends, By Category, and Over Time tabs
- Reports: "Show amounts" toggle displays values directly on chart bars and area curves
- Reports: filter panel with category checkboxes (search, select all/none) and source dropdown
- Reports: source filter applies at SQL level for accurate filtered totals
- Reports: sticky table headers on all report tables (Dynamic Report, Budget vs Actual)
- Reports: interactive hover — dimmed non-hovered bars, tooltip filtered to hovered category
- Reports: legend hover highlights category across all months (Over Time chart)
### Fixed
- Transaction table: comment icon now turns orange (like split icon) when a note is present (#7)
## [0.5.0]
### 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] [package]
name = "simpl-result" name = "simpl-result"
version = "0.6.3" version = "0.6.6"
description = "Personal finance management app" description = "Personal finance management app"
license = "GPL-3.0-only" license = "GPL-3.0-only"
authors = ["you"] authors = ["you"]

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types"; import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { new Intl.NumberFormat("en-CA", {
@ -25,55 +26,6 @@ interface BudgetVsActualTableProps {
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[],
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) {
if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
}
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
@ -151,7 +103,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> <tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom 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]">
{t("budget.category")} {t("budget.category")}
</th> </th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"> <th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
@ -206,25 +158,32 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null; const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
return ( return (
<Fragment key={section.type}> <Fragment key={section.type}>
<tr className="bg-[var(--muted)]/50"> <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"> <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)]">
{section.label} {section.label}
</td> </td>
</tr> </tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => { {reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent; const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isIntermediateParent = isParent && depth === 1; const isTopParent = isParent && depth === 0;
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; const isIntermediateParent = isParent && depth >= 1;
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr <tr
key={`${row.category_id}-${row.is_parent}-${depth}`} key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${ className={`border-b border-[var(--border)]/50 ${
isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" : isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : "" isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
}`} }`}
> >
<td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}> <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)]`
}`}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -256,8 +215,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr> </tr>
); );
})} })}
<tr className="border-b border-[var(--border)] bg-[var(--muted)]/40 font-semibold text-sm"> <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">{t(typeTotalKeys[section.type])}</td> <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"> <td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)} {cadFormatter(sectionTotals.monthActual)}
</td> </td>
@ -283,8 +242,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
); );
})} })}
{/* Grand totals */} {/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20"> <tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3">{t("common.total")}</td> <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"> <td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)} {cadFormatter(totals.monthActual)}
</td> </td>

View file

@ -107,7 +107,9 @@ export default function CategoryBarChart({
border: "1px solid var(--border)", border: "1px solid var(--border)",
borderRadius: "8px", borderRadius: "8px",
color: "var(--foreground)", color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}} }}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }} labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }} itemStyle={{ color: "var(--foreground)" }}
/> />

View file

@ -123,7 +123,9 @@ export default function CategoryOverTimeChart({
border: "1px solid var(--border)", border: "1px solid var(--border)",
borderRadius: "8px", borderRadius: "8px",
color: "var(--foreground)", color: "var(--foreground)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}} }}
wrapperStyle={{ zIndex: 50 }}
labelStyle={{ color: "var(--foreground)" }} labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }} itemStyle={{ color: "var(--foreground)" }}
filterNull filterNull

View file

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

View file

@ -12,6 +12,7 @@ import {
} from "../services/dashboardService"; } from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService"; import { getCategoryOverTime } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService"; import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
interface DashboardState { interface DashboardState {
summary: DashboardSummary; summary: DashboardSummary;
@ -19,6 +20,8 @@ interface DashboardState {
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[]; budgetVsActual: BudgetVsActualRow[];
period: DashboardPeriod; period: DashboardPeriod;
budgetYear: number;
budgetMonth: number;
customDateFrom: string; customDateFrom: string;
customDateTo: string; customDateTo: string;
isLoading: boolean; isLoading: boolean;
@ -38,6 +41,7 @@ type DashboardAction =
}; };
} }
| { type: "SET_PERIOD"; payload: DashboardPeriod } | { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }; | { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
const now = new Date(); const now = new Date();
@ -50,6 +54,8 @@ const initialState: DashboardState = {
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetVsActual: [], budgetVsActual: [],
period: "year", period: "year",
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
customDateFrom: yearStartStr, customDateFrom: yearStartStr,
customDateTo: todayStr, customDateTo: todayStr,
isLoading: false, isLoading: false,
@ -73,6 +79,8 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
}; };
case "SET_PERIOD": case "SET_PERIOD":
return { ...state, period: action.payload }; return { ...state, period: action.payload };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_CUSTOM_DATES": case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo }; return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
default: default:
@ -80,68 +88,28 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
} }
} }
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "year":
from = new Date(year, 0, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
default:
from = new Date(year, month, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export function useDashboard() { export function useDashboard() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetchData = useCallback(async (period: DashboardPeriod, customFrom?: string, customTo?: string) => { const fetchData = useCallback(async (
period: DashboardPeriod,
customFrom: string | undefined,
customTo: string | undefined,
bYear: number,
bMonth: number,
) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([ const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
getDashboardSummary(dateFrom, dateTo), getDashboardSummary(dateFrom, dateTo),
getExpensesByCategory(dateFrom, dateTo), getExpensesByCategory(dateFrom, dateTo),
getCategoryOverTime(dateFrom, dateTo), getCategoryOverTime(dateFrom, dateTo),
getBudgetVsActualData(currentYear, currentMonth), getBudgetVsActualData(bYear, bMonth),
]); ]);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
@ -156,8 +124,8 @@ export function useDashboard() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchData(state.period, state.customDateFrom, state.customDateTo); fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
}, [state.period, state.customDateFrom, state.customDateTo, fetchData]); }, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
const setPeriod = useCallback((period: DashboardPeriod) => { const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period }); dispatch({ type: "SET_PERIOD", payload: period });
@ -167,5 +135,9 @@ export function useDashboard() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } }); dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []); }, []);
return { state, setPeriod, setCustomDates }; const setBudgetMonth = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
}, []);
return { state, setPeriod, setCustomDates, setBudgetMonth };
} }

View file

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

View file

@ -319,6 +319,7 @@
"actual": "Actual", "actual": "Actual",
"difference": "Difference", "difference": "Difference",
"annual": "Annual", "annual": "Annual",
"previousYear": "Prev. Year",
"splitEvenly": "Split evenly across 12 months", "splitEvenly": "Split evenly across 12 months",
"annualMismatch": "Annual total does not match the sum of monthly amounts", "annualMismatch": "Annual total does not match the sum of monthly amounts",
"clickToEdit": "Click to edit", "clickToEdit": "Click to edit",
@ -376,7 +377,8 @@
"ytd": "Year-to-Date", "ytd": "Year-to-Date",
"dollarVar": "$ Var", "dollarVar": "$ Var",
"pctVar": "% Var", "pctVar": "% Var",
"noData": "No budget or transaction data for this period." "noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for"
}, },
"dynamic": "Dynamic Report", "dynamic": "Dynamic Report",
"export": "Export", "export": "Export",

View file

@ -319,6 +319,7 @@
"actual": "Réel", "actual": "Réel",
"difference": "Écart", "difference": "Écart",
"annual": "Annuel", "annual": "Annuel",
"previousYear": "Année préc.",
"splitEvenly": "Répartir également sur 12 mois", "splitEvenly": "Répartir également sur 12 mois",
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels", "annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
"clickToEdit": "Cliquer pour modifier", "clickToEdit": "Cliquer pour modifier",
@ -376,7 +377,8 @@
"ytd": "Cumul annuel", "ytd": "Cumul annuel",
"dollarVar": "$ \u00c9cart", "dollarVar": "$ \u00c9cart",
"pctVar": "% \u00c9cart", "pctVar": "% \u00c9cart",
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode." "noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
"titlePrefix": "Budget vs Réel pour le mois de"
}, },
"dynamic": "Rapport dynamique", "dynamic": "Rapport dynamique",
"export": "Exporter", "export": "Exporter",

View file

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

View file

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

View file

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

View file

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

123
src/utils/dateRange.test.ts Normal file
View file

@ -0,0 +1,123 @@
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");
});
});

66
src/utils/dateRange.ts Normal file
View file

@ -0,0 +1,66 @@
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) };
});
}

38
src/utils/reorderRows.ts Normal file
View file

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