Compare commits

..

32 commits

Author SHA1 Message Date
le king fu
9a88e32a06 chore: release v0.13.0
All checks were successful
Release / build-and-release (push) Successful in 23m25s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:12:10 -04:00
le king fu
0ae7ea0887 state: rework #259 — wrong module, wrong premise
The issue body claimed StarterAccountsModal auto-maps starter accounts to
existing profile accounts, and that the checkbox is disabled when no similar
account is found. Neither holds: no mapping exists in that flow at all, and the
checkbox is disabled when a collision IS detected (inverse polarity).

The real gap, confirmed with Max, is in the category migration: custom
categories land in plan.preserved without ever being run through the matching
engine, render as plain text with no picker, and are dumped under the
"Catégories personnalisées (migration)" catch-all by the writer.

Issue rewritten on Forgejo (merge semantics, status:ready), original body kept
in a comment. #259 is not part of epic #260 — that epic now has no leftover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:01:56 -04:00
le king fu
05d76205a0 state: sync after M2 rapports-parite shipped (#277-#279)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:28:11 -04:00
le king fu
a982f9ed9d Merge issue-279 (dashboard Cartes convergence) into main
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-07-11 21:22:29 -04:00
le king fu
6dbaaa25fe Merge issue-277 (BVA income-first) into main
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-07-11 21:21:20 -04:00
le king fu
e55c3bd250 Merge issue-278 (budget grid income-first) into main 2026-07-11 21:20:07 -04:00
le king fu
9ee5ad353f fix(reports): thread accountIds into Cartes KPI + seasonality series
All checks were successful
PR Check / rust (pull_request) Successful in 21m29s
PR Check / frontend (pull_request) Successful in 2m24s
getCartesSnapshot forwarded the account filter only to the top-movers and
budget sub-reports, leaving fetchMonthlyFlows (KPIs, sparklines, 12-month
overlay) and fetchSeasonality unfiltered. The Dashboard is the first page to
expose the account filter over these series, so its KPI cards showed
unfiltered totals while the category bars / trend / top-movers respected the
filter — figures that did not reconcile on the same screen.

Thread an optional accountIds through both fetchers (parameterized
`source_id IN (...)` via inPlaceholders) and pass it from getCartesSnapshot,
so the whole dashboard honours the filter — which is what the #279 CHANGELOG
entry already promises. No CHANGELOG change: the code now matches the note.

Resolves #279

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:16:09 -04:00
le king fu
093ba83c51 state: correct M1 vitest count (791, not 4676 — worktree recursion)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:16:39 -04:00
le king fu
193ef1d9dd feat(dashboard): converge home page on the Cartes report model
All checks were successful
PR Check / rust (pull_request) Successful in 20m42s
PR Check / frontend (pull_request) Successful in 2m22s
DashboardPage now reuses the Cartes report's presentational widgets
(KpiCard with MoM/YoY deltas, top movers, budget adherence) sourced from
getCartesSnapshot against a Dashboard-owned reference month (defaults to
the last complete month). The expense-only pie chart is replaced by a
ranked bar chart of top expense categories (resurrecting the previously
unused CategoryBarChart), and the account (import-source) filter
introduced on Trends/Compare/Budget now also applies to the Dashboard's
own transactional widgets via a Dashboard-local accountIds state (kept
separate from useReportsPeriod, since the Dashboard owns its own two
temporal axes).

A new net-worth tile surfaces the Balance sheet's latest total
(getSnapshotTotalsByDate) — a distinct metric from every transactional
card here, so it stays hidden (never a misleading "$0") until at least
one balance account has a recorded snapshot, reusing deriveLandingState
rather than re-inferring emptiness from nulls. It is not scoped by the
account filter (balance_accounts is a disjoint concept from
import_sources) and is fetched independently on mount.

The category-over-time trend chart now passes typeFilter "expense",
fixing a latent mismatch where a revenue category could silently show
up in a chart titled "expenses over time".

Resolves #279
2026-07-11 17:13:23 -04:00
le king fu
14755eb155 feat(reports): BVA (actual vs budget) income-first + result lines
All checks were successful
PR Check / rust (pull_request) Successful in 20m38s
PR Check / frontend (pull_request) Successful in 2m20s
Align BudgetVsActualTable on the income-statement gold standard already
shipped for the real-vs-real compare report (#253/#256): flip TYPE_ORDER
to income-first (revenue -> expense -> transfer) and replace the flat
"Total" row with interleaved Result before transfers / Net result lines.

Roll-up logic extracted into a new pure, tested module
(budgetVsActualResults.ts) mirroring compareResults.ts, with one sign
difference: BVA amounts already arrive signed the accounting way
(expense actual/budget are negative), so the operating result is a
straight add of income + expense rather than a subtraction.

Resolves #277

Generated autonomously by /autopilot run of 2026-07-11
2026-07-11 16:59:16 -04:00
le king fu
ab67c68605 feat(budget): flip grid to income-first + add Résultat rows
All checks were successful
PR Check / rust (pull_request) Successful in 21m4s
PR Check / frontend (pull_request) Successful in 2m20s
Align BudgetTable on the income-statement standard shipped for the
compare/trend reports: sections now read Income -> Expense -> Transfer
(useBudget's TYPE_ORDER and BudgetTable's typeOrder both flipped), and
the previously unlabeled grand-total row is replaced by two interleaved
result rows (Result before transfers / Net result), computed by a new
pure, tested module (budgetTableResults.ts) mirroring compareResults.ts's
shape. Roll-up covers the previous-year-actual, budgeted-annual, and
budgeted-monthly columns alike.

All categories remain displayed (the grid stays a full edit surface) --
no collapse, no empty-row toggle, matching this issue's frozen decisions.

Resolves #278
2026-07-11 16:59:01 -04:00
le king fu
26896bbf03 state: sync after M1 filtres-fondation merged (#272-#276)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:42:56 -04:00
le king fu
fe9ae0118c feat(reports): adopt shared FilterPanel on Compare and Budget
Mounts <FilterPanel> on ReportsComparePage (next to the existing
CompareReferenceMonthPicker) and BudgetPage (next to the existing
YearNavigator), keeping each page's temporal control unchanged. Threads
accountIds from useReportsPeriod through useCompare into
getCompareMonthOverMonth/getCompareYearOverYear, and through
CompareBudgetView into getBudgetVsActualData — closing the gap where
that sub-tab silently ignored the filter while the rest of the Compare
page respected it.

Budget: the issue named getBudgetVsActualData as the target for
useBudget, but that hook never calls it (it's exclusive to
CompareBudgetView/Dashboard/Cartes) — its only real actuals fetch is
getActualTotalsForYear(year - 1), the previous-year reference column.
Extended that function with the same optional accountIds pass-through
established by #273, rather than wiring in an unrelated call shape.

Both hooks fetch their account checkbox list via getAllImportSources,
mirroring the useTrends/#275 pattern. Empty selection = no filter,
byte-identical to pre-#276 output (regression-tested).

Resolves #276

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:52:21 -04:00
le king fu
30682004d3 feat(reports): adopt shared FilterPanel on the Trends page
Mounts <FilterPanel> (from #274) on ReportsTrendsPage next to the period
selector, and threads accountIds from useReportsPeriod through useTrends
into getMonthlyTrends/getCategoryOverTime (both already accountIds-aware
since #273). The account checkbox list reuses the same getAllImportSources
query as the Transactions single-source filter. Empty selection = no
filter, byte-identical to pre-#275 output.

Resolves #275
2026-07-11 15:41:13 -04:00
le king fu
70b2cd2e42 feat(reports): shared FilterPanel component (temporal slot + account multi-select)
Adds src/components/reports/FilterPanel.tsx, the shared filter bar for the
report pages. It renders the page's own temporal control (PeriodSelector /
CompareReferenceMonthPicker / YearNavigator / …) as-is via a `temporalControl`
ReactNode prop, plus a checkbox multi-select over `accounts: ImportSource[]`.
No `temporalMode` enum — that design was rejected to avoid mixing state
sources; the panel owns no temporal state at all.

Empty `accountIds` means no filter (all sources). Selection toggling is a
pure, exported `toggleAccountId` helper. Copy says "sources"/"import
sources" everywhere, never "comptes"/"accounts", so the filter reads as
distinct from the Bilan module's own "Compte" vocabulary.

This issue creates the standalone component only; wiring it into report
pages is left to follow-up issues (#275/#276).

Resolves #274
2026-07-11 15:31:58 -04:00
le king fu
b40381fb89 docs(reports): fix stale COMPARE_DELTA_SQL comment references
The constant became the compareDeltaSql() function in the previous
commit (Issue #273); three comments still named the old constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:17:48 -04:00
le king fu
4a93c60ea7 feat(reports): plumb accountIds[] account filter into 7 report services
Give every report service an optional accountIds?: number[] filter that
narrows results to a subset of import sources (transactions.source_id),
matched via a parameterized IN (...) clause — one bound placeholder per
id, never a joined string (CWE-89). New shared inPlaceholders() helper
(src/utils/sqlFilters.ts) generates the placeholder list so the pattern
lives in exactly one place.

New accountIds param: getCompareMonthOverMonth, getCompareYearOverYear,
getBudgetVsActualData, getCartesSnapshot (forwarded to its
getCompareMonthOverMonth + getBudgetVsActualData sub-calls, so the
Cartes dashboard's top-movers and budget-adherence cards respect an
active filter instead of silently ignoring it).

Signature change scalar -> plural: getMonthlyTrends, getCategoryOverTime,
getExpensesByCategory (sourceId?: number -> accountIds?: number[]). No
scalar production caller of these three passed sourceId today, so only
the test call-sites needed updating to the array shape.

Omitted/empty accountIds adds no clause at all, so every service stays
byte-identical to its pre-#273 SQL/results — pinned by a regression test
per service (new budgetService.test.ts / dashboardService.test.ts files,
extended reportService.test.ts / reportService.cartes.test.ts).

This is backend plumbing only: no report page exposes an account filter
control yet (follow-up issues #274-#276 add the shared <FilterPanel> and
wire it into Trends/Compare/Budget).

Resolves #273

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:15:37 -04:00
le king fu
65be6c7482 feat(reports): extend useReportsPeriod with account filter foundation
All checks were successful
PR Check / rust (pull_request) Successful in 21m28s
PR Check / frontend (pull_request) Successful in 2m22s
Add accountIds: number[] to the shared useReportsPeriod hook, URL-backed
via a dedicated `sources` query param (comma-separated, bookmarkable like
the existing period/from/to). Purely additive: the hook's existing shape
is unchanged, so all 6 consumers (useTrends, useCompare, useCategoryZoom,
ReportsCategoryPage, ReportsPage, ReportsComparePage) keep compiling as-is.

Also adds the shared ReportFilters { period, accountIds } type for
follow-up issues to consume, and exports pure parseAccountIds/
serializeAccountIds helpers (same hookless-testability pattern as
resolveReportsPeriod). URL parsing validates each token as a finite
integer via a strict regex + Number.isSafeInteger, dropping invalid
tokens individually rather than discarding the whole selection.

This is foundation only: no UI control exposes the filter yet, and no
service reads accountIds yet (both land in follow-up issues of the
"rapports uniformes" epic).

Resolves #272

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:01:43 -04:00
le king fu
a8e3775b8b state: plan #260 suite into 2 overnight milestones (#272-#279)
Prepared via /plan-overnight + /review-spec: M1 filtres-fondation
(#272-#276, shared multi-account filter) and M2 rapports-parite
(#277-#279, BVA/budget income-first + dashboard Cartes convergence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:46:13 -04:00
le king fu
b956091138 state: tendances hierarchiques (#260 slice) shipped — #262-#265 merged
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:35:54 -04:00
le king fu
fe87313ba3 Merge PR #267/#268/#269: tendances hierarchiques (#263/#264/#265) 2026-07-07 20:31:26 -04:00
le king fu
5b94c154f7 Merge PR #266: bugfix(reports) tendances byCategory+table (#262) 2026-07-07 20:31:18 -04:00
le king fu
36abad7a2e feat(reports): CategoryOverTimeTable hierarchical + collapse + result interleave
Renders the trends-by-category table from the #264 id-keyed tree as an indented
parent/child hierarchy (parity with ComparePeriodTable): top-level parents carry
a chevron and fold their subtree down to a subtotal, with an "expand all /
collapse all" button. Collapsed by default; expansion state is persisted under a
trends-specific localStorage key (reports-trends-expanded), distinct from the
comparable tables. The "result before transfers" line is interleaved before the
transfers section instead of sitting at the bottom.

Collapse is purely visual: section subtotals, before/net results and totals come
from computeOverTimeResults over the raw tree, never the visible rows.

New pure module overTimeTableModel.ts (section grouping + collapse accessors)
keeps the component thin and unit-testable without a React render harness;
overTimeResults.ts is left untouched. 8 new tests; build + 728 vitest green.

Resolves #265

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:46:35 -04:00
le king fu
19cdad2718 feat(reports): getCategoryOverTime -> id-keyed trends tree (sidecar) + tree-based results
Adds an id-keyed hierarchical `tree: OverTimeRow[]` sidecar to
getCategoryOverTime, built via the generic buildLeafDrivenTree (#263) as
buildOverTimeTree — leaf-driven, income-first, no top-N / no "Other" bucket.
The name-keyed pivot (data/categories/colors/types/categoryIds) is left
BYTE-IDENTICAL, so the Trends chart and the dashboard render unchanged.

computeOverTimeResults now consumes the id-keyed tree LEAVES (never the
`is_parent` subtotals) grouped by each leaf's category_id-resolved type, so
the section subtotals and the Result-before/net lines are exact: two homonym
categories of different types no longer collide, and a non-top-N income or
transfer category is no longer lumped into "Other" as an expense.

CategoryOverTimeTable renders the tree leaves (every category, id-safe)
instead of the top-N pivot; the chart keeps reading the top-N-capped pivot so
its stacked series stay bounded. OverTimeRow mirrors CategoryDelta's snake_case
hierarchy block (parent_id/is_parent/depth/category_type) so it composes with
collapsibleRows / useCollapsibleGroups for the #265 hierarchy work.

Tests: rewrote overTimeResults.test.ts onto the tree (homonym regression +
leaves-only-not-parents); added buildOverTimeTree suite (nesting, income-first,
grand-total invariance, orphan, A->B->A cycle depth guard) and a
getCategoryOverTime tree-wiring test proving the pivot stays top-N+Other while
the tree carries every category. 720 vitest green, build + tsc green.

Resolves #264

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:30:50 -04:00
le king fu
5a1ce42034 refactor(reports): extract a generic leaf-driven tree builder
All checks were successful
PR Check / rust (pull_request) Successful in 23m41s
PR Check / frontend (pull_request) Successful in 2m27s
Factor the hand-rolled hierarchy walk out of buildCompareTree into a
generic buildLeafDrivenTree<T>(leaves, opts): the shared skeleton (leaf/
orphan split, ancestor "relevant" set, DB-order adjacency, buildNode
recursion with the MAX_TREE_DEPTH guard + loop-break, magnitude sibling
ordering, orphan append, contiguous income->expense->transfer section
sort) now lives in one place, parameterised by injection points
(categoryIdOf, makeLeaf, makeSubtotal, decorateDirectLeaf, makeOrphan,
sortKey, isSubtotal, sectionOf, sectionOrder, maxDepth).

buildCompareTree becomes a thin specialisation supplying only the delta
arithmetic; output is byte-identical (compare tests unchanged as the
non-regression guard). Exported (with TreeCatMeta / TreeSectionType /
LeafDrivenTreeOptions) so the trends tree reuses the exact same skeleton.

Behavior-preserving refactor: no user-facing change, no CHANGELOG entry,
no DB migration.

Resolves #263

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:07:20 -04:00
le king fu
64ae00a847 bugfix(reports): open Trends on byCategory + table by default
All checks were successful
PR Check / rust (pull_request) Successful in 24m51s
PR Check / frontend (pull_request) Successful in 2m34s
The Trends report previously opened on the global monthly chart, which
has no income section — so a user's revenue (e.g. their pay) was hidden
until they manually switched to the "By category" table view.

Open the report on byCategory + table by default so the income section
(and therefore payroll) is visible immediately. Two scoped call-site
changes:
- useTrends initial subView: "global" -> "byCategory"
- ReportsTrendsPage viewMode fallback: readViewMode(key, "table")

Both defaults are scoped to Trends. The real-vs-real compare report
(distinct "reports-viewmode-compare" storage key) is unaffected, and a
user's own persisted view choice still wins over the new fallback.

Resolves #262

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:58:25 -04:00
le king fu
5ade8650cf state: overnight-2026-07-08-tendances-hierarchiques prepared (#260 slice)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:47:03 -04:00
le king fu
192c0078a4 state: sync after #254 (collapse/expand) shipped + #258 closed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:48:02 -04:00
0a55a47ab5 Merge PR #261: feat(reports) collapse/expand sub-categories (#254) 2026-07-07 11:45:44 +00:00
le king fu
616cccec37 feat(reports): collapse/expand sub-categories in comparable reports (#254)
All checks were successful
PR Check / rust (pull_request) Successful in 22m52s
PR Check / frontend (pull_request) Successful in 2m24s
The two hierarchical comparable reports — real-vs-real Compare and
real-vs-budget — now let the user collapse or expand each top-level
category's sub-categories. Groups start collapsed by default (#260): only
the parent's subtotal row shows until the user expands it, and each expanded
group is remembered per report.

- New pure module utils/collapsibleRows.ts (visibility filter, group-key
  extraction, expanded-set (de)serialization) + useCollapsibleGroups hook
  wrapping the localStorage-persisted expanded set. Persisting the *expanded*
  set (not the collapsed one) makes "all collapsed" the zero/default state.
- A chevron toggles each top-level parent; an "Expand all / Collapse all"
  button toggles them together. Section subtotals and result lines keep
  summing every leaf, so collapsing never changes any total.
- Accessors mirror each table's own depth/parent logic so hidden rows are
  exactly a group's indented descendants.
- i18n keys reports.collapse.{expandAll,collapseAll} (FR/EN); CHANGELOG.

CategoryOverTimeTable (Trends -> by category) is intentionally left out: its
rows are a flat top-N category list with no parent/child hierarchy to fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:18:52 -04:00
le king fu
ababa3c010 docs(release): note FR changelog header is [Non publié], not [Unreleased]
Avoids the false 'empty FR changelog' diagnosis during the version-migration
step of the /release skill. Committing a WIP edit orphaned from session 5466da98.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:05:16 -04:00
le king fu
36385320a6 state: sync after v0.12.0 — analyse de résultat (#253/#256) shipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:07:55 -04:00
52 changed files with 3925 additions and 893 deletions

View file

@ -2,7 +2,7 @@
name: release name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push) description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true user-invocable: true
updated: 2026-04-19 updated: 2026-07-01
--- ---
# /release — Release Simpl-Resultat # /release — Release Simpl-Resultat
@ -24,9 +24,9 @@ updated: 2026-04-19
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9) - `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective. - Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** : 3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
- `CHANGELOG.md` (EN) - `CHANGELOG.md` (EN) : header `## [Unreleased]`
- `CHANGELOG.fr.md` (FR) - `CHANGELOG.fr.md` (FR) : header `## [Non publié]` (PAS `[Unreleased]` — ne pas le confondre avec un changelog vide)
- Pattern de migration : transformer `## [Unreleased]` en `## [X.Y.Z] - YYYY-MM-DD`, puis **recreer une section `## [Unreleased]` vide au-dessus** pour accueillir les prochaines entrees. Ne pas deplacer le contenu — les sections sont laissees en place. - Pattern de migration : transformer le header « non publié » de chaque fichier en `## [X.Y.Z] - YYYY-MM-DD`, puis **recreer la section « non publié » vide au-dessus** pour accueillir les prochaines entrees. Le header differe selon la langue. Ne pas deplacer le contenu — les sections sont laissees en place.
4. Si changement d'architecture : mettre a jour `docs/architecture.md` 4. Si changement d'architecture : mettre a jour `docs/architecture.md`
5. Commit : `chore: release vX.Y.Z` (ajouter les 7 fichiers : 5 bumps + 2 changelogs) 5. Commit : `chore: release vX.Y.Z` (ajouter les 7 fichiers : 5 bumps + 2 changelogs)
6. Tag annote (permet une release notes par tag, lisible via `git show vX.Y.Z`) : 6. Tag annote (permet une release notes par tag, lisible via `git show vX.Y.Z`) :
@ -48,3 +48,4 @@ updated: 2026-04-19
## Changelog ## Changelog
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle) - 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)
- 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98.

View file

@ -2,6 +2,29 @@
## [Non publié] ## [Non publié]
## [0.13.0] - 2026-07-12
### Ajouté
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272).
- Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273).
- Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274).
- Rapports → Tendances : le panneau de filtre partagé apparaît désormais sur ce rapport, à côté du sélecteur de période. Cocher une ou plusieurs sources d'import restreint à la fois la vue mensuelle globale et le tableau de résultat par catégorie à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#275).
- Rapports → Comparaison et Budget : le panneau de filtre partagé apparaît désormais aussi sur ces deux rapports, à côté de leur contrôle de période existant (la comparaison garde son sélecteur de mois de référence, la grille budget garde son navigateur d'année — inchangés). Cocher une ou plusieurs sources d'import restreint le rapport de comparaison — y compris son onglet budget-vs-réel — et la colonne de référence de l'année précédente de la grille budget à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#276).
### Corrigé
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat couvre désormais **toutes** les catégories au lieu des 50 plus grosses, et ses lignes de Résultat sont exactes. Deux catégories différentes portant le même nom ne sont plus fusionnées, et un petit revenu ou transfert n'est plus comptabilisé comme une dépense — les revenus et le résultat net s'additionnent donc correctement (#264).
### Modifié
- Rapports → Tendances : le rapport **s'ouvre désormais sur la vue « Par catégorie » en tableau** par défaut, au lieu du graphique mensuel global. Le tableau par catégorie comporte une section revenus, donc une catégorie de revenu (ex. votre paie) apparaît d'emblée — sans changer de vue au préalable. La vue que vous choisissez vous-même reste mémorisée (#262).
- Rapports → Comparaison (réel vs budget) : le tableau budget-vs-réel se lit désormais comme les autres rapports comparables — une **analyse de résultat**. Les catégories sont regroupées Revenus → Dépenses → Transferts (au lieu de Dépenses → Revenus → Transferts), et l'ancienne ligne « Total » plate est remplacée par une ligne **Résultat avant transferts** (affichée lorsque des transferts existent) et une ligne **Résultat net**, toutes deux colorées en vert pour un surplus, en rouge pour un déficit. Les montants de catégorie et de section sont inchangés (#277).
- Budget : la grille budget se lit désormais comme une analyse de résultat — les catégories sont regroupées **Revenus → Dépenses → Transferts** (auparavant dépenses en premier), et l'ancienne ligne « Total » brute est remplacée par deux lignes de résultat : **Résultat avant transferts** (revenus dépenses) et **Résultat net** (après transferts), calculées sur les colonnes année précédente, annuelle et mensuelles. Toutes les catégories restent affichées sur la grille — elle demeure une surface d'édition, sans ligne masquée ni repliable (#278).
- Tableau de bord (`/`) : la page d'accueil adopte désormais le modèle du rapport Cartes. Les cartes d'indicateurs (revenus, dépenses, résultat net, taux d'épargne) affichent leur variation vs le mois précédent et vs l'an dernier (sélecteur de mois de référence, par défaut le dernier mois complet) ; les catégories en hausse/en baisse et le respect du budget sont présentés de la même façon que sur Cartes. Une nouvelle tuile **valeur nette** affiche le dernier total du Bilan — une métrique différente de toutes les autres cartes de la page, donc masquée (jamais un « 0 $ » trompeur) tant qu'aucun compte de bilan n'a de snapshot enregistré, et jamais affectée par le filtre de comptes ci-dessous. Le camembert des dépenses est remplacé par des **barres classées** des catégories de dépense les plus importantes, et le filtre de comptes (sources d'import) — introduit sur Tendances/Comparaison/Budget — s'applique désormais aussi aux widgets transactionnels du tableau de bord (#279).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05
### Modifié ### Modifié

View file

@ -2,6 +2,29 @@
## [Unreleased] ## [Unreleased]
## [0.13.0] - 2026-07-12
### Added
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272).
- Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273).
- Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274).
- Reports → Trends: the shared filter panel now appears on this report, next to the period selector. Checking one or more import sources narrows both the global monthly view and the by-category result table to those sources; leaving none checked keeps showing every source, unchanged (#275).
- Reports → Compare & Budget: the shared filter panel now appears on both reports too, next to their existing period control (the compare report keeps its reference-month picker, the budget grid keeps its year navigator — unchanged). Checking one or more import sources narrows the compare report — including its budget-vs-actual tab — and the budget grid's previous-year reference column to those sources; leaving none checked keeps showing every source, unchanged (#276).
### Fixed
- Reports → Trends → by category (table view): the income-statement table now covers **every** category instead of only the 50 largest, and its Result lines are exact. Two different categories that happen to share a name are no longer merged together, and a smaller income or transfer category is no longer miscounted as an expense — so the revenues and the net result add up correctly (#264).
### Changed
- Reports → Trends: the report now **opens on the "By category" table view** by default, instead of the global monthly chart. The by-category table has an income section, so a revenue category (e.g. your pay) shows up straight away — no need to switch views first. Whatever view you pick yourself is still remembered (#262).
- Reports → Compare (actual vs budget): the budget-vs-actual table now reads the same way as the other comparable reports — an **income statement**. Categories are grouped Income → Expenses → Transfers (instead of Expenses → Income → Transfers), and the old flat "Total" row is replaced by a **Result before transfers** line (shown when transfers exist) and a **Net result** line, both coloured green for a surplus and red for a deficit. Leaf and section figures are unchanged (#277).
- Budget: the budget grid now reads as an income statement — categories are grouped **Income → Expenses → Transfers** (previously expenses first), and the old plain "Total" row is replaced by two result lines: **Result before transfers** (income expenses) and **Net result** (after transfers), computed across the previous-year, annual, and monthly columns alike. Every category still shows on the grid — it stays an editable surface, with no rows hidden or collapsed (#278).
- Dashboard (`/`): the home page now matches the Cartes report's model. KPI cards for income, expenses, net result and savings rate show their change vs the previous month and vs last year (reference-month picker, defaulting to the last complete month); rising/falling categories and budget adherence are shown the same way as on Cartes. A new **net worth** tile shows the Balance sheet's latest total — a different metric from every other card here, so it stays hidden (never a misleading "$0") until at least one balance account has a recorded snapshot, and it is never affected by the account filter below. The expense-only pie chart is replaced by a **ranked bar chart** of top expense categories, and the account (import-source) filter — introduced on Trends/Compare/Budget — now also applies to the Dashboard's own transactional widgets (#279).
## [0.12.0] - 2026-07-05 ## [0.12.0] - 2026-07-05
### Changed ### Changed

View file

@ -1,6 +1,6 @@
# STATE — Simpl'Résultat # STATE — Simpl'Résultat
> Derniere MAJ : 2026-07-05 (v0.11.0 shippée — milestone `overnight-2026-07-05-bilan-rapports-ux` 5/5 fermée : rapports hiérarchiques, netting transferts, landing Bilan tuiles, import CSV titres, migration cible éditable) > Derniere MAJ : 2026-07-12 (**#259 recadrée** après `/analyze` : son corps décrivait un flux inexistant du Bilan, le vrai sujet est le **mapping manuel des catégories custom** en migration de taxonomie → réécrite `status:ready`, **hors epic #260** ; l'epic #260 n'a donc plus de reliquat. Précédemment 2026-07-11 : **M2 « rapports-parite » #277-#279 shippée** sur `main` `a982f9e` — /pr-review ×3 [APPROVE #285/#286, REQUEST_CHANGES #287 → point I7 « filtrage Cartes » confirmé + fix `9ee5ad3`], merge local pile de 3 + union CHANGELOG, tip cumulé validé **811 vitest** + 98 Rust, réconciliation Forgejo complète. **30 commits non taggés depuis `v0.12.0`** — release en attente)
## Position actuelle ## Position actuelle
@ -12,18 +12,21 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
**v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe). **v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe).
**v0.12.0 shippée** (2026-07-05, tag `v0.12.0` → CI release #311) — recadrage de #243 (montants de transfert inclus dans le comparable) par Max en « analyse de résultat » : deux rapports comparables refondus en états de résultat (income statement). Le compare réel-vs-réel (#253) et le tableau de tendance par catégorie (#256) affichent le **revenu**, un **Résultat avant transferts** (revenus dépenses) et un **Résultat net** (après transferts), sections ordonnées revenu→dépense→transfert ; les rapports comparables ouvrent par défaut sur le **mois complet précédent** (#253). Modules purs extraits et testés (`compareResults.ts`, `overTimeResults.ts`) ; 2 blocages `/pr-review` corrigés (fuite du revenu dans les top-movers Cartes ; garde StrictMode sur le sync du mois de référence). Livré en 2 issues/PRs stackées #255→#257 (merge local + réconciliation Forgejo, branches supprimées), 701 vitest verts, aucune migration DB (v1→v16 inchangées), 20 tables / 24 index. Backlog ouvert au passage : #258 (db-locked `proposeStarterAccounts`) → fermé le 2026-07-07 (déjà corrigé v0.10.1, pas de nesting) ; #254 (collapse/expand) → livré le 2026-07-07 (PR #261) ; #259 (mapping manuel d'un compte standard sans similaire auto) et #260 (epic « rapports uniformes » — tous les rapports sur le modèle income-statement) ouverts.
## Decisions recentes ## Decisions recentes
- 2026-07-12 : **#259 recadrée — l'issue décrivait un flux qui n'existe pas** (rectifie les entrées des 2026-07-05 / 07-11 qui la classaient « mapping manuel compte sans similaire auto » et « reliquat de l'epic #260 » : les deux sont faux). Le corps d'origine, rédigé à chaud le 2026-07-05 pendant le run v0.12.0, avait **transposé par analogie** le signalement de Max vers le module Bilan sans ouvrir le fichier : il décrivait une auto-association des comptes standards aux comptes existants du profil dans `StarterAccountsModal`, avec « case désactivée quand aucun similaire n'est auto-identifié ». **Aucune notion de mapping n'existe dans ce flux** — la case est un simple « créer ce compte : oui/non », désactivée **quand une collision EST détectée** (`StarterAccountsModal.tsx:160`), soit la polarité inverse ; le commentaire `l.8` cité (« the matching checkbox ») désignait « la case **correspondante** », pas « la case de matching ». **Le vrai sujet** (confirmé par Max) est la **migration des catégories** : `computeMigrationPlan` range les catégories en 2 seaux et un seul est éditable — `plan.rows` (seed) passe par le moteur d'appariement + type-ahead par ligne (#246/#252), tandis que `plan.preserved` (catégories **custom**) est poussé avec `v1TargetId: null` **sans même être soumis au moteur d'appariement**, rendu en `<li>` texte brut (`StepSimulate.tsx:202-216`, aucun picker) et déversé d'office par le writer sous le fourre-tout « Catégories personnalisées (migration) ». Sémantique tranchée avec Max : **fusion** (choisir une feuille standard réassigne tx/budgets/mots-clés/fournisseurs et fait disparaître la custom ; ne rien choisir = comportement actuel, et ne doit **pas** bloquer le bouton « Suivant »). Re-parentage écarté. Travail = câblage sur 3 couches (UI `StepSimulate``MappingRow` réutilisable tel quel ; reducer `RESOLVE_ROW` qui ne voit que `plan.rows` ; 4 retouches du writer) — la machinerie de fusion est **déjà générique** (`buildMappingFromRows` filtre les cibles nulles, étapes 3-7 bouclent sur la Map). Complexité Medium. Piège à couvrir : fusionner une custom **parente ayant des enfants custom** (liste `preserved` plate → l'enfant non résolu retombe au fourre-tout, pas d'orphelin, mais aucun test ne le garantit). Leçon process : une issue rédigée par analogie en fin de run, sans lecture du code, peut inverser la prémisse **et** se tromper de module — le `/analyze` l'a rattrapée 6 jours plus tard. (ref #259)
- 2026-07-11 : **M2 « rapports-parite » (#277-#279) shippée** — dernière grosse tranche de l'epic #260 « rapports uniformes ». Run `/autopilot` (3 workers en worktree → PRs #285/#286/#287), reviewée via **3 `/pr-review` adversariales parallèles** (read-only, `git show` sans checkout) : **APPROVE #285** (grille budget income-first, #278) + **APPROVE #286** (BVA réel-vs-budget income-first, #277) + **REQUEST_CHANGES #287** (dashboard Cartes, #279). Blocage #287 = **le point I7 prédit au plan** confirmé réel : `getCartesSnapshot` propageait `accountIds` aux sous-rapports top-movers/budget mais **pas** aux séries `fetchMonthlyFlows` (KPIs/sparklines/overlay 12 mois) ni `fetchSeasonality` → sur le dashboard (1re page à exposer le filtre par-dessus ces séries) les KPI montraient des totaux non filtrés à côté de barres/tendance filtrées. **Fix** (`9ee5ad3`) : threader `accountIds?` dans les 2 fetchers (`source_id IN (...)` paramétré via `inPlaceholders`, index `$3`/`$4`) + câblage `getCartesSnapshot` ; le CHANGELOG #279 promettait déjà le filtrage → **code aligné sur la promesse, texte inchangé** ; +2 assertions cartes. **Merge local de la pile** (3 merges `--no-ff`, conflit additif CHANGELOG ×2 résolu par union #277/#278/#279 — i18n **non conflictuel** car #277/#278 réutilisent des clés existantes, seul #279 touche les locales ; STATE non conflictuel car branches ne l'ont pas commité) → tip cumulé validé (tsc + vite build + **811 vitest** + cargo check + **98 Rust**) → push `main` `a982f9e`. Réconciliation Forgejo : 3 issues auto-fermées via `Resolves #N`, 3 PRs fermées manuellement (merges locaux non détectés *merged*), milestone `overnight-2026-07-08-rapports-parite` **3/3 fermée**, branches supprimées, worktrees leftover nettoyés (gotcha recursion). Aucune migration DB (v1→v16), non taggé ([Unreleased]). Wrinkle process : le fork du skill `/pr-review` avait posté un APPROVE prématuré sur #287 (I7 gradé non bloquant) → superséé + commentaire stale supprimé par la passe adversariale indépendante (trace code + « 1re page à exposer le filtre » + CHANGELOG à honorer) ; valeur de la double-vérif indépendante. Suivi non bloquant : `getDashboardSummary` = code mort à retirer. Reste de #260 : #259 (mapping manuel compte sans similaire auto). (ref #260, #277-#279, PRs #285-#287)
- 2026-07-08 : **Suite #260 — M1 « filtres-fondation » mergée** (`main` `fe9ae01`), M2 prête. Planifiée via `/plan-overnight`+`/review-spec` (filtre **multi-comptes** `accountIds[]`/`IN(...)` tranché après analyse, révise le « sourceId singulier » initial). **M1 livrée** via `/autopilot` (5 workers séquentiels en worktree → PRs #280-#284 en **pile linéaire**) + `/pr-review` **APPROVE ×5** + **merge local fast-forward** : hook `useReportsPeriod` additif (+`accountIds` URL `sources` + type `ReportFilters`, #272), 7 services en `accountIds[]` via helper paramétré `sqlFilters.inPlaceholders` (#273), `<FilterPanel>` slot temporel + multi-select « sources d'import » (libellé distinct du Bilan, #274), adoption Tendances (#275) + Compare/Budget (#276 — incl. `CompareBudgetView` ; câblage budget via `getActualTotalsForYear`, le body citait à tort `getBudgetVsActualData`). Aucune migration DB. Build + **791 vitest** verts (728 + 63). NB : le « 4676 / 277 fichiers » vu en pré-merge était une **pollution de worktrees**`vitest` récursait dans les 5 worktrees leftover sous `.claude/worktrees/` (791 × ~6) ; nettoyés depuis, compteur réel = 791. Gotcha : nettoyer les worktrees (ou exclure `.claude/worktrees/`) avant de valider un tip. Réconciliation Forgejo complète (5 issues auto-fermées via `Resolves #N`, 5 PRs fermées, milestone fermée, branches supprimées ; worktrees nettoyés). **M2 `overnight-2026-07-08-rapports-parite` (#277-#279) prête**`/autopilot` (BVA + grille budget income-first + dashboard Cartes). Notes review pour M2 : séries `getCartesSnapshot` (`fetchMonthlyFlows`/`fetchSeasonality`) non filtrées (I7/#279) ; grille budget câblée via `getActualTotalsForYear`. (ref #260, #272-#276, PRs #280-#284)
- 2026-07-07 : **Tendances hiérarchiques livrées** (1re slice de l'epic #260 « rapports uniformes »). Préparée via `/plan-overnight` (13 décisions drainées ; `/review-spec` 3 experts → 3 critiques résolus par l'approche **sidecar** : garder le pivot par mois pour chart **+ dashboard** (`DashboardPage:142` = consommateur partagé oublié), ajouter un arbre **clé-par-id** pour le tableau — pivot clé-par-nom aurait faussé le Résultat net sur homonymes une fois le `LIMIT 50` retiré), puis exécutée **le même jour** via `/autopilot` (session interactive, ciblée par nom de milestone **hors fenêtre horaire** — cf. mémoire [[feedback-plan-execute-temporal-decoupling]]). 4 workers en worktree → issues **#262-#265**, `/pr-review` **APPROVE ×4**, merge local de la pile (#266 standalone + chaîne #267→#268→#269 ; CHANGELOG **auto-résolu par section**, vérifié) ; tip cumulé validé build + **728 vitest** → push `main` `fe87313`, réconciliation Forgejo (4 PRs fermées, 4 issues auto-fermées via `Resolves #N`, milestone `overnight-2026-07-08-tendances-hierarchiques` fermée, branches supprimées). Contenu : tendance par catégorie → tableau income-statement **hiérarchique + collapse replié-par-défaut** (défaut byCategory+table = fix « paie invisible »), `getCategoryOverTime` en **arbre id-keyed sidecar** via `buildLeafDrivenTree<T>` générique extrait de `buildCompareTree` (behavior-preserving), Résultat avant transferts interleavé. Aucune migration DB, non taggé ([Unreleased]). 2 polish non bloquants : perf dashboard (arbre construit non lu, #264), DRY `typeOf` dupliqué (#265). Reste de #260 (BVA-au-standard, budget, dashboard, filtres partagés) différé. (ref #260, #262-#265, PRs #266-#269)
- 2026-07-07 : #254 (collapse/expand des sous-catégories) livré + #258 (db-locked) fermé. **#254** : chevron par catégorie parente + « tout replier/déplier » sur les 2 rapports comparables hiérarchiques (`ComparePeriodTable` réel-vs-réel, `BudgetVsActualTable` réel-vs-budget) ; **repli par défaut** (contrainte remontée de #260), état persisté par rapport en localStorage. Repli purement visuel — sous-totaux/totaux/résultats calculés sur les lignes brutes, jamais les visibles (tracé en /pr-review). Module `collapsibleRows.ts` (pur) + hook `useCollapsibleGroups.ts` + 12 tests → **713 vitest**, aucune migration DB. `CategoryOverTimeTable` exclu (liste plate top-N, rien à replier) → relève de l'epic #260. Livré via sous-agent en worktree isolé + /pr-review APPROVE + merge API (single PR, pas de conflit i18n/CHANGELOG) → PR #261, #254 auto-fermée. **#258** fermé comme déjà corrigé en v0.10.1 (`8cb7e53`, présent v0.11.0/v0.12.0) : la piste « BEGIN imbriqué » du body était une mauvaise lecture — `withTransaction` (`db.ts`) n'émet aucun BEGIN, `proposeStarterAccountsInTransaction:720` est le seul (même pattern que `saveSnapshotAtomic`/`upsertSnapshotLines` qui ne deadlockent jamais) ; smell latent noté (garde `inTransaction` = booléen global, pas un compteur → requête simple concurrente pendant une transaction bypasse le verrou). Prochaine étape : /plan-overnight de l'epic #260. (ref #254/#258, PR #261)
- 2026-07-05 : **v0.12.0 shippée** (tag `v0.12.0` → CI release #311). Compare réel-vs-réel (#253) + tableau de tendance par catégorie (#256) refondus en **analyse de résultat** : revenu affiché, **Résultat avant transferts** (revenus dépenses) + **Résultat net** (après transferts), sections revenu→dépense→transfert ; rapports comparables par défaut au **mois complet précédent** (#253). Origine : #243 (montants de transfert inclus dans le comparable) recadré par Max en « voir si je suis over/under ». Livré en 2 issues/PRs stackées #255→#257 (merge local + réconciliation Forgejo, PRs fermées, branches supprimées). Modules purs `compareResults.ts`/`overTimeResults.ts` testés ; 2 blocages `/pr-review` corrigés (fuite du revenu dans les top-movers Cartes via `COMPARE_DELTA_SQL` élargi income+transfer → filtre expense-only + régression ; garde StrictMode value-change sur le sync mois-réf, `syncReferenceOnPeriodChange` pur). 701 vitest verts, aucune migration DB. Ouvert au passage : #258 (db-locked `proposeStarterAccounts`, probable BEGIN imbriqué dans `withTransaction`), #259 (mapping manuel compte sans similaire), #254 (collapse/expand). Suivi différé : aligner le placement « Résultat avant transferts » de `CategoryOverTimeTable` (bas) sur le compare (interleavé). (ref #253/#256, PRs #255/#257)
- 2026-07-05 : Run `/autopilot` sur `overnight-2026-07-05-bilan-rapports-ux` (préparée la veille via `/analyze` #243-#246 + split #247) → 5 issues livrées, PRs #248-#252 toutes `/pr-review` APPROVE. **Merge local de la pile** (l'API merge échoue sur les conflits i18n/CHANGELOG garantis — 4 fichiers partagés `fr.json`/`en.json`/`CHANGELOG.md`/`CHANGELOG.fr.md`, voir mémoire [[reference-shared-conflict-files]]) ; réconciliation Forgejo (PRs fermées manuellement, issues auto-fermées via `Resolves #N`, milestone fermée) ; release **v0.11.0** taggée. Validation locale du tip cumulé : build + **679 vitest** + cargo verts (`check.yml` ne tourne ni sur push `main` ni sur les bases non-`main` type #249). Polish #252 post-review : picker migration feuilles-seulement + helper `comboboxCategoriesForTarget` testé. (ref #243-#247, PRs #248-#252) - 2026-07-05 : Run `/autopilot` sur `overnight-2026-07-05-bilan-rapports-ux` (préparée la veille via `/analyze` #243-#246 + split #247) → 5 issues livrées, PRs #248-#252 toutes `/pr-review` APPROVE. **Merge local de la pile** (l'API merge échoue sur les conflits i18n/CHANGELOG garantis — 4 fichiers partagés `fr.json`/`en.json`/`CHANGELOG.md`/`CHANGELOG.fr.md`, voir mémoire [[reference-shared-conflict-files]]) ; réconciliation Forgejo (PRs fermées manuellement, issues auto-fermées via `Resolves #N`, milestone fermée) ; release **v0.11.0** taggée. Validation locale du tip cumulé : build + **679 vitest** + cargo verts (`check.yml` ne tourne ni sur push `main` ni sur les bases non-`main` type #249). Polish #252 post-review : picker migration feuilles-seulement + helper `comboboxCategoriesForTarget` testé. (ref #243-#247, PRs #248-#252)
- 2026-07-04 : Override `@babel/core` mergé (issue #241, PR #242 — review /pr-review APPROVE + CI verte rust/frontend) — clôt le reliquat `@babel/core` low du sprint `deps-security-2026-07`. GHSA-4x5r-pxfx-6jf8 (arbitrary file read via `sourceMappingURL`, CVSS 3.2, dev/build-only via `@vitejs/plugin-react`) : `npm audit fix` inopérant (version pinnée par le parent → empile une copie sans résoudre), fix = `overrides` scopé `{"@vitejs/plugin-react":{"@babel/core":"^7.29.7"}}` → patch 7.x non-breaking (7.29.0→7.29.7), arbre 109→109 (pas d'explosion), `npm audit` 1→0. Build + 635 vitest verts. Détecté via /analyse-vulnerabilite — rapport VPS `--fresh` inaccessible (auth navigateur Tailscale SSH en attente, jamais reçu le lien) → `npm audit` live utilisé ; rapport local périmé (2026-05-06) portait 1 faux positif `secrets` (« Generic Token Assignment » sur `balance.service.ts`, contenu disparu depuis la réécriture Bilan). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #241, PR #242) - 2026-07-04 : Override `@babel/core` mergé (issue #241, PR #242 — review /pr-review APPROVE + CI verte rust/frontend) — clôt le reliquat `@babel/core` low du sprint `deps-security-2026-07`. GHSA-4x5r-pxfx-6jf8 (arbitrary file read via `sourceMappingURL`, CVSS 3.2, dev/build-only via `@vitejs/plugin-react`) : `npm audit fix` inopérant (version pinnée par le parent → empile une copie sans résoudre), fix = `overrides` scopé `{"@vitejs/plugin-react":{"@babel/core":"^7.29.7"}}` → patch 7.x non-breaking (7.29.0→7.29.7), arbre 109→109 (pas d'explosion), `npm audit` 1→0. Build + 635 vitest verts. Détecté via /analyse-vulnerabilite — rapport VPS `--fresh` inaccessible (auth navigateur Tailscale SSH en attente, jamais reçu le lien) → `npm audit` live utilisé ; rapport local périmé (2026-05-06) portait 1 faux positif `secrets` (« Generic Token Assignment » sur `balance.service.ts`, contenu disparu depuis la réécriture Bilan). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #241, PR #242)
- 2026-07-01 : Sprint `deps-security-2026-07` livré (4/4, milestone fermée) via /sprint — 4 vulns Defenseur (2026-06-30) remédiées en 2 PRs séquentielles (fichier partagé `package.json` → pas de worktrees parallèles). PR A #239 : `react-router-dom` 7.13→7.18.1 ferme #235 (racine, 7 advisories dont turbo-stream RCE) + #238 (0 CVE propre, héritage transitif — 1 bump du parent suffit, `npm audit viaParents`). PR B #240 : `vite` 6.4.2→6.4.3 (#236) + `vitest` 4.0.18→4.1.9 (#237), restés en 6.x/4.x (majors 8.x/latest écartés), 635 vitest verts (bump vitest 4.0→4.1 sans régression). `npm audit` 5→1 ; reste `@babel/core` low volontairement hors scope (`npm audit fix` ajoute 77 paquets sans résoudre, pinné par parent → override scopé dédié à faire). Surface réelle faible (app Tauri desktop, pas de serveur SSR/RSC). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #235-#238, PRs #239/#240) - 2026-07-01 : Sprint `deps-security-2026-07` livré (4/4, milestone fermée) via /sprint — 4 vulns Defenseur (2026-06-30) remédiées en 2 PRs séquentielles (fichier partagé `package.json` → pas de worktrees parallèles). PR A #239 : `react-router-dom` 7.13→7.18.1 ferme #235 (racine, 7 advisories dont turbo-stream RCE) + #238 (0 CVE propre, héritage transitif — 1 bump du parent suffit, `npm audit viaParents`). PR B #240 : `vite` 6.4.2→6.4.3 (#236) + `vitest` 4.0.18→4.1.9 (#237), restés en 6.x/4.x (majors 8.x/latest écartés), 635 vitest verts (bump vitest 4.0→4.1 sans régression). `npm audit` 5→1 ; reste `@babel/core` low volontairement hors scope (`npm audit fix` ajoute 77 paquets sans résoudre, pinné par parent → override scopé dédié à faire). Surface réelle faible (app Tauri desktop, pas de serveur SSR/RSC). Voir mémoire [[reference-analyse-vulnerabilite-inflation-transitive]] (ref #235-#238, PRs #239/#240)
- 2026-06-30 : hotfix v0.10.1 shippé + déployé (PR #230) — corrige le « database is locked » 0.10.0 (après abandon d'un snapshot en cours). Cause racine : `tauri-plugin-sql` charge SQLite via un pool sqlx multi-connexions (max 10) sans primitive de transaction JS → `db.execute("BEGIN")`/`"COMMIT"` en appels séparés pouvaient frapper des connexions différentes → transaction d'écriture zombie. Fix : sérialisation FIFO de tout l'accès DB dans `db.ts` ; `withTransaction()` tient le verrou sur tout le `BEGIN..COMMIT` ; appliqué aux 5 sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) par extraction de helper en place ; `db.test.ts` (régression). + console de log : `getLogs` retourne un snapshot immuable (live-update) + API `logInfo/logWarn/logError`. 635 vitest, CI vert. Voir mémoire [[reference-tauri-sql-transactions]] (ref #230) - 2026-06-30 : hotfix v0.10.1 shippé + déployé (PR #230) — corrige le « database is locked » 0.10.0 (après abandon d'un snapshot en cours). Cause racine : `tauri-plugin-sql` charge SQLite via un pool sqlx multi-connexions (max 10) sans primitive de transaction JS → `db.execute("BEGIN")`/`"COMMIT"` en appels séparés pouvaient frapper des connexions différentes → transaction d'écriture zombie. Fix : sérialisation FIFO de tout l'accès DB dans `db.ts` ; `withTransaction()` tient le verrou sur tout le `BEGIN..COMMIT` ; appliqué aux 5 sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) par extraction de helper en place ; `db.test.ts` (régression). + console de log : `getLogs` retourne un snapshot immuable (live-update) + API `logInfo/logWarn/logError`. 635 vitest, CI vert. Voir mémoire [[reference-tauri-sql-transactions]] (ref #230)
- 2026-06-29 : #228 mergé (PR #229) — garde d'abort migration v16 scopée aux comptes convertibles (`JOIN balance_categories` + `c.asset_type IS NOT NULL` dans Migration v16 + V16_SQL + V16_CORRUPT). Un compte simple portant un symbole résiduel (recat priced→simple) avec lignes qty-NULL ne fait plus aborter v16 → l'app redémarre. Test régression `migration_v16_leaves_simple_account_with_residual_symbol_intact`. CI vert (#229 → main, contrairement à la pile #219-#227). Milestone overnight-2026-06-05-bilan-detail-titres complète 10/10, fermée. Reste : tag release 0.10.0 (ref #228) - 2026-06-29 : #228 mergé (PR #229) — garde d'abort migration v16 scopée aux comptes convertibles (`JOIN balance_categories` + `c.asset_type IS NOT NULL` dans Migration v16 + V16_SQL + V16_CORRUPT). Un compte simple portant un symbole résiduel (recat priced→simple) avec lignes qty-NULL ne fait plus aborter v16 → l'app redémarre. Test régression `migration_v16_leaves_simple_account_with_residual_symbol_intact`. CI vert (#229 → main, contrairement à la pile #219-#227). Milestone overnight-2026-06-05-bilan-detail-titres complète 10/10, fermée. Reste : tag release 0.10.0 (ref #228)
- 2026-06-09 : Etape 2 bilan « detail par titre » livree — pile de 9 PRs stackees #219-#227 mergee bottom-up (merge manuel via API Forgejo, pas /fix-issue ; retarget auto base->main entre chaque), milestone overnight-2026-06-05-bilan-detail-titres 9/10, issues #210-#218 fermees. `balance_securities` + `balance_snapshot_holdings`, `account.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion comptes cotes -> detailles 1-position, service securities transactionnel, reducer holdings + dispatch account.kind, UI SecurityPicker + wizard date-pivot, drill-down + gain latent, ADR 0015 Accepted. Validation locale pre-merge (build + 627 vitest + cargo check + 97 Rust) car check.yml ne couvre pas les PRs vers bases non-main. Reste #228 (fix-forward garde v16) avant tag release (ref #210-#218, #219-#227)
- 2026-06-01 : Etape 1 bilan « axe vehicule » livree via /sprint (4 issues, PRs #206-#209 mergees, milestone fermee). `vehicle_type` nullable = enveloppe fiscale sur le compte ; categorie = pure classe d'actif (5) ; migrations additives v12/v13 (v1-v11 intactes, checksum SHA-384) ; reclass ex-CELI/REER -> Autres ; `custom_label` (fix bug I renommage) ; toggle graphique classe/enveloppe + rendements repliables persistes. ADR 0014 Accepted, ADR 0012 Rejected. Etape 2 (detail par titre) reportee (ref #202-#205, #206-#209)
- 2026-05-31 : Audit bilan (revue CPA + UX) -> `docs/audit-bilan-2026-05.md` ; Etape 0 quick wins mergee (#201) : terminologie categorie->type, symbole optionnel pour comptes cotes, date de snapshot deplacable (ref #198/#199/#200). Racine identifiee : modele plat fusionne vehicule fiscal x classe d'actif ; chantier structurel a specifier, ADR 0012 a superseder
- 2026-05-10 : Release v0.9.1 + note changelog maximus-api activation post-0.9.0 (ref #197)
- 2026-05-09 : ADR 0013 — stocks provider evaluation, AlphaVantage retenu comme bascule cible (ref #196)
## Blockers actifs ## Blockers actifs

View file

@ -172,7 +172,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) | | `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
| `adjustmentService.ts` | Gestion des ajustements | | `adjustmentService.ts` | Gestion des ajustements |
| `budgetService.ts` | Gestion budgétaire | | `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord | | `dashboardService.ts` | Agrégation données tableau de bord : `getExpensesByCategory` (barres classées, `accountIds`), `getDashboardSummary` (non consommé depuis #279), `deriveNetWorthTile` (pur — tuile valeur nette du Bilan, réutilise `deriveLandingState`) |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) | | `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `dataExportService.ts` | Export de données (chiffré) | | `dataExportService.ts` | Export de données (chiffré) |
| `userPreferenceService.ts` | Stockage préférences utilisateur | | `userPreferenceService.ts` | Stockage préférences utilisateur |
@ -205,8 +205,8 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useImportHistory` | Historique des imports | | `useImportHistory` | Historique des imports |
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord | | `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) | | `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
| `useHighlights` | Panneau de faits saillants du hub rapports | | `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) | | `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) | | `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
@ -350,7 +350,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| Route | Page | Description | | Route | Page | Description |
|-------|------|-------------| |-------|------|-------------|
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) | | `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
| `/import` | `ImportPage` | Assistant d'import CSV | | `/import` | `ImportPage` | Assistant d'import CSV |
| `/transactions` | `TransactionsPage` | Liste avec filtres | | `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/categories` | `CategoriesPage` | Gestion hiérarchique | | `/categories` | `CategoriesPage` | Gestion hiérarchique |

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.12.0", "version": "0.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.12.0", "version": "0.13.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View file

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

2
src-tauri/Cargo.lock generated
View file

@ -4509,7 +4509,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "simpl-result" name = "simpl-result"
version = "0.12.0" version = "0.13.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "simpl-result" name = "simpl-result"
version = "0.12.0" version = "0.13.0"
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.12.0", "version": "0.13.0",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View file

@ -3,6 +3,7 @@ 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"; import { reorderRows } from "../../utils/reorderRows";
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
style: "currency", style: "currency",
@ -127,7 +128,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
grouped[key].push(row); grouped[key].push(row);
} }
const typeOrder = ["expense", "income", "transfer"] as const; // Income-statement reading order: revenue first, then expenses, then
// transfers (Issue #278) — matches the compare/trend reports' ordering.
const typeOrder = ["income", "expense", "transfer"] as const;
const typeLabelKeys: Record<string, string> = { const typeLabelKeys: Record<string, string> = {
expense: "budget.expenses", expense: "budget.expenses",
income: "budget.income", income: "budget.income",
@ -139,19 +142,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
transfer: "budget.totalTransfers", transfer: "budget.totalTransfers",
}; };
// Column totals with sign convention (only count leaf rows to avoid double-counting parents) // Income-statement roll-up (Issue #278): Résultat avant transferts / net,
const monthTotals: number[] = Array(12).fill(0); // computed on the budgeted + previous-year-actual leaves. Mathematically
let annualTotal = 0; // equivalent to the old plain grand-total (income expense + transfer over
let prevYearTotal = 0; // every leaf), now surfaced as the two labeled result rows below instead of
for (const row of rows) { // a single unlabeled "Total".
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting const results = computeBudgetResults(rows);
const sign = signFor(row.category_type);
for (let m = 0; m < 12; m++) {
monthTotals[m] += row.months[m] * sign;
}
annualTotal += row.annual * sign;
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
}
const totalCols = 15; // category + prev year + annual + 12 months const totalCols = 15; // category + prev year + annual + 12 months
@ -295,39 +291,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
); );
}; };
return ( // One type's section: header, its (reorderable) rows, and a leaf-summed
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"> // subtotal row. Extracted so it can be called for income/expense, then
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]"> // again for transfer once the Résultat rows are interleaved between them.
<button const renderTypeSection = (type: (typeof typeOrder)[number]) => {
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
{t("budget.category")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.previousYear")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")}
</th>
{MONTH_KEYS.map((key) => (
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
{t(key)}
</th>
))}
</tr>
</thead>
<tbody>
{typeOrder.map((type) => {
const group = grouped[type]; const group = grouped[type];
if (!group || group.length === 0) return null; if (!group || group.length === 0) return null;
const sign = signFor(type); const sign = signFor(type);
@ -367,18 +334,76 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</tr> </tr>
</Fragment> </Fragment>
); );
})} };
{/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"> // A Résultat row (avant-transferts subtotal, or the net bottom line).
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td> // `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td> // the softer variant mirrors the per-type section-subtotal row above.
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td> const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
{monthTotals.map((total, mIdx) => ( const rowClass = strong
<td key={mIdx} className="py-3 px-2 text-right text-sm"> ? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
{formatSigned(total)} : "bg-[var(--muted)]/40 border-b border-[var(--border)]";
const stickyBg = strong ? "bg-[var(--muted)]" : "bg-[var(--muted)]/40";
const cellWeight = strong ? "" : "font-semibold";
const pad = strong ? "py-3" : "py-2.5";
return (
<tr key={labelKey} className={rowClass}>
<td className={`${pad} px-3 sticky left-0 z-10 text-sm ${stickyBg}`}>{t(labelKey)}</td>
<td className={`${pad} px-2 text-right text-sm ${cellWeight} text-[var(--muted-foreground)]`}>
{formatSigned(tot.previousYearTotal)}
</td>
<td className={`${pad} px-2 text-right text-sm ${cellWeight}`}>{formatSigned(tot.annual)}</td>
{tot.months.map((val, mIdx) => (
<td key={mIdx} className={`${pad} px-2 text-right text-sm ${cellWeight}`}>
{formatSigned(val)}
</td> </td>
))} ))}
</tr> </tr>
);
};
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
{t("budget.category")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.previousYear")}
</th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")}
</th>
{MONTH_KEYS.map((key) => (
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
{t(key)}
</th>
))}
</tr>
</thead>
<tbody>
{renderTypeSection("income")}
{renderTypeSection("expense")}
{/* Operating result (revenues expenses), interleaved before the
transfers section only when transfers exist otherwise it
equals the net result below and would just be noise. */}
{results.hasTransfers &&
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
{renderTypeSection("transfer")}
{/* Bottom line: result after netting transfers. */}
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import type { BudgetYearRow } from "../../shared/types";
import { computeBudgetResults, sumLeavesForType, signForBudgetType } from "./budgetTableResults";
// Minimal leaf factory. `months` arrives as the positive-magnitude budgeted
// figure BudgetTable itself flips by type via `signFor` — same convention
// `sumLeavesForType` re-implements. `annual` always equals `sum(months)`
// (useBudget derives it that way; see useBudget.ts's `buildMonths`).
function leaf(
type: BudgetYearRow["category_type"],
months: number[],
previousYearTotal = 0,
overrides: Partial<BudgetYearRow> = {},
): BudgetYearRow {
return {
category_id: 1,
category_name: type,
category_color: "#000",
category_type: type,
parent_id: null,
is_parent: false,
depth: 0,
months,
annual: months.reduce((s, v) => s + v, 0),
previousYearTotal,
...overrides,
};
}
const FLAT12 = (v: number) => Array(12).fill(v) as number[];
describe("computeBudgetResults — income-statement roll-up (Issue #278)", () => {
it("resultBefore = budgeted income expense; resultNet adds transfers", () => {
const r = computeBudgetResults([
leaf("income", FLAT12(400)), // annual 4800
leaf("expense", FLAT12(300)), // annual 3600, stored positive, flipped to -300/mo
leaf("transfer", FLAT12(0)),
]);
expect(r.resultBefore.months[0]).toBe(100); // 400 300
expect(r.resultBefore.annual).toBe(1200); // 4800 3600
expect(r.resultNet.annual).toBe(1200); // balanced (0) transfer → unchanged
expect(r.hasTransfers).toBe(true);
});
it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => {
const r = computeBudgetResults([
leaf("income", FLAT12(1000)),
leaf("expense", FLAT12(700)),
leaf("transfer", FLAT12(50)), // uncategorized single leg, signed pass-through
]);
expect(r.resultBefore.annual).toBe(3600); // (1000 700) × 12
expect(r.resultNet.annual).toBe(4200); // resultBefore + transfer(50×12)
});
it("a deficit yields a negative net result and no transfer line when absent", () => {
const r = computeBudgetResults([leaf("income", FLAT12(200)), leaf("expense", FLAT12(250))]);
expect(r.resultNet.months[0]).toBe(-50);
expect(r.hasTransfers).toBe(false);
});
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
const parent = leaf("expense", FLAT12(999), 0, { is_parent: true });
const r = computeBudgetResults([parent, leaf("expense", FLAT12(100))]);
expect(r.expense.annual).toBe(-1200); // only the leaf's 100 × 12 (flipped), not 999
});
it("carries the previous-year actual through unsigned (already signed in the DB)", () => {
// Actual totals arrive pre-signed: expense negative, income positive.
const r = computeBudgetResults([
leaf("income", FLAT12(100), 1150),
leaf("expense", FLAT12(60), -650),
]);
expect(r.resultBefore.previousYearTotal).toBe(500); // 1150 + (650), no re-sign
});
it("sumLeavesForType filters by type and excludes parents", () => {
const t = sumLeavesForType(
[
leaf("expense", FLAT12(50)),
leaf("expense", FLAT12(999), 0, { is_parent: true }),
leaf("income", FLAT12(10)),
],
"expense",
);
expect(t.annual).toBe(-600); // 50 × 12, flipped negative
expect(t.months[0]).toBe(-50);
});
it("signForBudgetType flips expense only", () => {
expect(signForBudgetType("expense")).toBe(-1);
expect(signForBudgetType("income")).toBe(1);
expect(signForBudgetType("transfer")).toBe(1);
});
});

View file

@ -0,0 +1,94 @@
import type { BudgetYearRow } from "../../shared/types";
export type BudgetSectionType = "income" | "expense" | "transfer";
/**
* Sign multiplier applied to budgeted (months/annual) magnitudes. Budgeted
* amounts are entered/stored as positive magnitudes regardless of type and
* flipped for display by category type mirrors `BudgetTable`'s `signFor`.
*/
export function signForBudgetType(type: BudgetSectionType): 1 | -1 {
return type === "expense" ? -1 : 1;
}
/**
* Aggregate of the budget grid's 3 comparable column groups, already signed:
* 12 monthly (budgeted) totals, the annual (budgeted) total, and the
* previous-year (actual) total.
*/
export interface BudgetTotals {
/** index 0-11 = Jan-Dec, signed budgeted total */
months: number[];
/** signed budgeted annual total */
annual: number;
/** signed actual total from the previous year (already signed in the DB) */
previousYearTotal: number;
}
function zeroTotals(): BudgetTotals {
return { months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0 };
}
/**
* Sum every non-subtotal (leaf) row of a single type into one `BudgetTotals`.
*
* The type's sign is applied to the budgeted figures (`months`/`annual`),
* matching how `BudgetTable` displays them. `previousYearTotal` is NOT
* re-signed: it comes from `getActualTotalsForYear` (a plain signed
* `SUM(amount)` over the transactions table), already negative for expenses
* and ~0-net for balanced transfers the same convention `compareResults.ts`
* relies on for its own previous-period figures.
*/
export function sumLeavesForType(rows: BudgetYearRow[], type: BudgetSectionType): BudgetTotals {
const sign = signForBudgetType(type);
const totals = zeroTotals();
for (const row of rows) {
if (row.is_parent || row.category_type !== type) continue;
for (let m = 0; m < 12; m++) totals.months[m] += row.months[m] * sign;
totals.annual += row.annual * sign;
totals.previousYearTotal += row.previousYearTotal;
}
return totals;
}
/** Component-wise sum of two BudgetTotals (both already signed). */
function combine(a: BudgetTotals, b: BudgetTotals): BudgetTotals {
return {
months: a.months.map((v, i) => v + b.months[i]),
annual: a.annual + b.annual,
previousYearTotal: a.previousYearTotal + b.previousYearTotal,
};
}
export interface BudgetResults {
income: BudgetTotals;
expense: BudgetTotals;
transfer: BudgetTotals;
/** Budgeted/actual revenues expenses: the operating result, before transfers. */
resultBefore: BudgetTotals;
/** resultBefore + transfers: the bottom-line total. */
resultNet: BudgetTotals;
hasTransfers: boolean;
}
/**
* Income-statement roll-up for the budget grid (Issue #278), mirroring
* `compareResults.ts`'s shape (see that module's doc comment for the sign
* convention this relies on).
*
* Operates on the flat `BudgetYearRow[]` the grid already renders (produced
* by `useBudget`) LEAVES only (`is_parent === false`), so parent subtotal
* rows are never double-counted. All three column groups (previous-year
* actual, budgeted annual, budgeted monthly) are folded into the same result
* rows so "Résultat avant transferts" / "Résultat net" read consistently
* across every column of the grid.
*/
export function computeBudgetResults(rows: BudgetYearRow[]): BudgetResults {
const income = sumLeavesForType(rows, "income");
const expense = sumLeavesForType(rows, "expense");
const transfer = sumLeavesForType(rows, "transfer");
const resultBefore = combine(income, expense);
const resultNet = combine(resultBefore, transfer);
const hasTransfers = rows.some((r) => !r.is_parent && r.category_type === "transfer");
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
}

View file

@ -0,0 +1,62 @@
// NetWorthTile — Dashboard KPI-row tile showing the Bilan's latest net worth.
//
// Issue #279. Deliberately NOT `HubNetBalanceTile` (that one is the reports
// hub's transaction-P&L net balance — a different metric). This tile mirrors
// `BalanceOverviewCard`'s "latest total" figure and reuses its exact i18n
// copy (`balance.overview.latestTotal` / `asOf`) so the label is unambiguous
// next to the transactional KPI cards and the "import sources" account
// filter — same words as the Bilan page itself, never "balance"/"solde".
//
// Renders nothing when `data.visible` is false (no balance account yet, or
// accounts but no snapshot yet) — never a misleading "$0" tile.
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Wallet } from "lucide-react";
import type { NetWorthTileData } from "../../services/dashboardService";
export interface NetWorthTileProps {
data: NetWorthTileData;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
export default function NetWorthTile({ data }: NetWorthTileProps) {
const { t, i18n } = useTranslation();
if (!data.visible || data.total === null || data.asOfDate === null) {
return null;
}
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
const formattedDate = new Date(data.asOfDate).toLocaleDateString(dateLocale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<Link
to="/balance"
data-kpi="net-worth"
className="flex-1 min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3 hover:border-[var(--primary)] transition-colors"
>
<div className="text-sm text-[var(--muted-foreground)] flex items-center gap-1.5">
<Wallet size={14} aria-hidden="true" />
<span>{t("balance.overview.latestTotal")}</span>
</div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{formatCurrency(data.total, i18n.language)}
</div>
<div className="text-xs text-[var(--muted-foreground)] pt-1 border-t border-[var(--border)]">
{t("balance.overview.asOf", { date: formattedDate })}
</div>
</Link>
);
}

View file

@ -1,8 +1,11 @@
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types"; import type { BudgetVsActualRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./budgetVsActualResults";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { new Intl.NumberFormat("en-CA", {
@ -26,6 +29,17 @@ interface BudgetVsActualTableProps {
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
// Collapse groups keyed by category id; depth mirrors the render logic below
// (a missing depth is derived from parent_id) so hidden rows are exactly a
// group's indented descendants.
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
keyOf: (row) => String(row.category_id),
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
isParent: (row) => row.is_parent,
};
const BVA_EXPANDED_STORAGE_KEY = "reports-bva-expanded";
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(() => {
@ -41,6 +55,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
}); });
}; };
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
const groups = useCollapsibleGroups<BudgetVsActualRow>(
BVA_EXPANDED_STORAGE_KEY,
BVA_COLLAPSE_ACCESSORS,
);
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -49,8 +69,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
); );
} }
// Group rows by type for section headers // Group rows into contiguous type sections (the service already type-sorts:
type SectionType = "expense" | "income" | "transfer"; // income → expense → transfer, Issue #277).
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = []; const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
const typeLabels: Record<SectionType, string> = { const typeLabels: Record<SectionType, string> = {
expense: t("budget.expenses"), expense: t("budget.expenses"),
@ -72,25 +92,176 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
sections[sections.length - 1].rows.push(row); sections[sections.length - 1].rows.push(row);
} }
// Grand totals (leaf rows only) // Income-statement result lines (Issue #277): income + expense (expense
const leaves = data.filter((r) => !r.is_parent); // already arrives signed negative in BudgetVsActualRow — see
const totals = leaves.reduce( // budgetVsActualResults.ts), then the net after adding transfers. Replaces
(acc, r) => ({ // the old flat grand total row.
monthActual: acc.monthActual + r.monthActual, const results = computeResults(data);
monthBudget: acc.monthBudget + r.monthBudget, const nonTransferSections = sections.filter((s) => s.type !== "transfer");
monthVariation: acc.monthVariation + r.monthVariation, const transferSection = sections.find((s) => s.type === "transfer");
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget, const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => {
ytdVariation: acc.ytdVariation + r.ytdVariation, const sectionTotals = sumLeaves(section.rows);
}), const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget);
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 } const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget);
return (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
{section.label}
</td>
</tr>
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
}`}
>
<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)]`
}`}>
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</button>
) : (
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</span>
)}
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.monthActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{cadFormatter(row.monthVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{pctFormatter(row.monthVariationPct)}
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.ytdActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{cadFormatter(row.ytdVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{pctFormatter(row.ytdVariationPct)}
</td>
</tr>
); );
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null; })}
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null; <tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{cadFormatter(sectionTotals.monthVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{pctFormatter(sectionMonthPct)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.ytdActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{cadFormatter(sectionTotals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{pctFormatter(sectionYtdPct)}
</td>
</tr>
</Fragment>
);
};
// A result line (before-transfers subtotal or the net total). Every cell is
// coloured by its own sign — a surplus is green, a deficit red — mirroring
// ComparePeriodTable.renderResultRow (unlike normal rows, which only colour
// the variation/pct columns).
const renderResultRow = (labelKey: string, tot: Totals, strong: boolean) => {
const rowBg = strong
? "bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"
: "bg-[color-mix(in_srgb,var(--muted)_10%,var(--card))]";
const rowClass = strong
? `border-t-2 border-[var(--border)] font-bold text-sm ${rowBg}`
: `border-b border-[var(--border)] font-semibold text-sm ${rowBg}`;
const cell = "text-right px-3 py-3";
const monthPct = pct(tot.monthVariation, tot.monthBudget);
const ytdPct = pct(tot.ytdVariation, tot.ytdBudget);
return (
<tr key={labelKey} className={rowClass}>
<td className={`px-3 py-3 sticky left-0 z-10 ${rowBg}`}>{t(labelKey)}</td>
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.monthActual)}`}>
{cadFormatter(tot.monthActual)}
</td>
<td className={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>
{cadFormatter(tot.monthVariation)}
</td>
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>{pctFormatter(monthPct)}</td>
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.ytdActual)}`}>
{cadFormatter(tot.ytdActual)}
</td>
<td className={`${cell} ${variationColor(tot.ytdBudget)}`}>{cadFormatter(tot.ytdBudget)}</td>
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{cadFormatter(tot.ytdVariation)}</td>
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{pctFormatter(ytdPct)}</td>
</tr>
);
};
const hasGroups = groups.groupCount(data) > 0;
const allExpanded = groups.allExpanded(data);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button <button
onClick={toggleSubtotals} onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors" className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
@ -141,130 +312,15 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sections.map((section) => { {nonTransferSections.map(renderSection)}
const sectionLeaves = section.rows.filter((r) => !r.is_parent); {/* Operating result (income + expense), shown before the transfers
const sectionTotals = sectionLeaves.reduce( section only when transfers exist otherwise it equals the net
(acc, r) => ({ total below and would just be noise (mirrors ComparePeriodTable). */}
monthActual: acc.monthActual + r.monthActual, {results.hasTransfers &&
monthBudget: acc.monthBudget + r.monthBudget, renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
monthVariation: acc.monthVariation + r.monthVariation, {transferSection && renderSection(transferSection)}
ytdActual: acc.ytdActual + r.ytdActual, {/* Bottom line: result after adding transfers. */}
ytdBudget: acc.ytdBudget + r.ytdBudget, {renderResultRow("reports.compare.resultNet", results.resultNet, true)}
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
);
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
return (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
{section.label}
</td>
</tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent;
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.category_id}-${row.is_parent}-${depth}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
}`}
>
<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="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</span>
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.monthActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{cadFormatter(row.monthVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{pctFormatter(row.monthVariationPct)}
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.ytdActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{cadFormatter(row.ytdVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{pctFormatter(row.ytdVariationPct)}
</td>
</tr>
);
})}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{cadFormatter(sectionTotals.monthVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{pctFormatter(sectionMonthPct)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.ytdActual)}
</td>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{cadFormatter(sectionTotals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{pctFormatter(sectionYtdPct)}
</td>
</tr>
</Fragment>
);
})}
{/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
{cadFormatter(totals.monthVariation)}
</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
{pctFormatter(totalMonthPct)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.ytdActual)}
</td>
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
{cadFormatter(totals.ytdVariation)}
</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
{pctFormatter(totalYtdPct)}
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -59,6 +59,9 @@ export default function CategoryOverTimeChart({
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null); const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
// Reads the name-keyed pivot (`data.categories`), which stays top-N-capped
// (Issue #264 sidecar) so the stacked series never explode to 80+ layers. The
// full, uncapped hierarchy lives in `data.tree` and drives the table instead.
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name)); const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
const categoryEntries = visibleCategories.map((name, index) => ({ const categoryEntries = visibleCategories.map((name, index) => ({
name, name,

View file

@ -1,7 +1,15 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CategoryOverTimeData } from "../../shared/types"; import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { computeOverTimeResults, type OverTimeType } from "./overTimeResults"; import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
type OverTimeRenderSection,
} from "./overTimeTableModel";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
@ -33,12 +41,19 @@ const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
interface CategoryOverTimeTableProps { interface CategoryOverTimeTableProps {
data: CategoryOverTimeData; data: CategoryOverTimeData;
hiddenCategories?: Set<string>;
} }
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) { export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// Collapse/expand of sub-category groups — collapsed by default (issue #265),
// persisted under a key distinct from the comparable tables. Called before the
// early return so the hook order stays stable.
const groups = useCollapsibleGroups<OverTimeRow>(
OVERTIME_EXPANDED_STORAGE_KEY,
OVERTIME_COLLAPSE_ACCESSORS,
);
if (data.data.length === 0) { if (data.data.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -47,34 +62,21 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
); );
} }
const visibleCategories = hiddenCategories?.size // Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
? data.categories.filter((name) => !hiddenCategories.has(name)) // the result lines are exact, and homonym categories never collide.
: data.categories; const analysis = computeOverTimeResults(data);
const { months, hasTransfers, net, beforeTransfers } = analysis;
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data, visibleCategories); const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
const colSpan = months.length + 2; const colSpan = months.length + 2;
return ( const hasGroups = groups.groupCount(data.tree) > 0;
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> const allExpanded = groups.allExpanded(data.tree);
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap"> // A section = its header, its (visibly) indented hierarchy rows, and a
<thead className="sticky top-0 z-20"> // leaf-summed subtotal row. Only top-level parents collapse (parity with the
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> // comparable tables); the subtotal is always the reducer's leaf sum, so it
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]"> // never changes when a group is folded away.
{t("budget.category")} const renderSection = (section: OverTimeRenderSection) => (
</th>
{months.map((month) => (
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
{formatMonth(month)}
</th>
))}
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
{t("common.total")}
</th>
</tr>
</thead>
<tbody>
{sections.map((section) => (
<Fragment key={section.type}> <Fragment key={section.type}>
{/* Section header */} {/* Section header */}
<tr className="bg-[var(--muted)]"> <tr className="bg-[var(--muted)]">
@ -85,26 +87,65 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
{t(SECTION_LABEL_KEY[section.type])} {t(SECTION_LABEL_KEY[section.type])}
</td> </td>
</tr> </tr>
{/* Category rows — neutral levels, not deltas */} {/* Category rows — neutral leaf/parent magnitudes (not deltas) */}
{section.categories.map((category) => { {groups.visible(section.rows).map((row) => {
const rowTotal = data.data.reduce( const isParent = row.is_parent;
(sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), const depth = row.depth;
0, const isTopParent = isParent && depth === 0;
); const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
<tr key={category} className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"> <tr
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10"> key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
: isIntermediateParent
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
: "hover:bg-[var(--muted)]/40"
}`}
>
<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)]`
}`}
>
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</button>
) : (
<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"
style={{ backgroundColor: data.colors[category] }} style={{ backgroundColor: row.categoryColor }}
/> />
{category} {row.categoryName}
</span> </span>
)}
</td> </td>
{months.map((month) => { {months.map((month, monthIdx) => {
const monthData = data.data.find((d) => d.month === month); const value = row.monthly[monthIdx] ?? 0;
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
return ( return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums"> <td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"} {value ? cadFormatter(value) : "—"}
@ -112,7 +153,7 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
); );
})} })}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(rowTotal)} {cadFormatter(row.total)}
</td> </td>
</tr> </tr>
); );
@ -132,56 +173,78 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
</td> </td>
</tr> </tr>
</Fragment> </Fragment>
))} );
{/* Result before transfers — shown only when transfers exist */} // A result line (before-transfers subtotal or the net bottom line). Amounts are
{hasTransfers && ( // coloured by sign (surplus green / deficit red). Computed from the raw tree,
<tr className="border-t-2 border-[var(--border)] font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"> // so folding groups never moves these figures.
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10"> const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
{t("reports.compare.resultBeforeTransfers")}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(beforeTransfers.monthly[month]) }}
>
{cadFormatter(beforeTransfers.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(beforeTransfers.total) }}
>
{cadFormatter(beforeTransfers.total)}
</td>
</tr>
)}
{/* Net result — bottom line (income expenses + transfers) */}
<tr <tr
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${ className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
hasTransfers ? "border-b border-[var(--border)]" : "border-t-2 border-[var(--border)]" strong ? "border-t-2 border-[var(--border)]" : "border-b border-[var(--border)]"
}`} }`}
> >
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10"> <td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.resultNet")} {t(labelKey)}
</td> </td>
{months.map((month) => ( {months.map((month) => (
<td <td
key={month} key={month}
className="text-right px-3 py-3 tabular-nums" className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(net.monthly[month]) }} style={{ color: resultColor(series.monthly[month]) }}
> >
{cadFormatter(net.monthly[month])} {cadFormatter(series.monthly[month])}
</td> </td>
))} ))}
<td <td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums" className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(net.total) }} style={{ color: resultColor(series.total) }}
> >
{cadFormatter(net.total)} {cadFormatter(series.total)}
</td> </td>
</tr> </tr>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{hasGroups && (
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data.tree))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
{t("budget.category")}
</th>
{months.map((month) => (
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
{formatMonth(month)}
</th>
))}
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
{t("common.total")}
</th>
</tr>
</thead>
<tbody>
{nonTransferSections.map(renderSection)}
{/* Operating result (revenues expenses), interleaved before the
transfers section only when transfers exist otherwise it equals
the net total below and would just be noise. */}
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
{transferSection && renderSection(transferSection)}
{/* Bottom line: result after netting transfers. */}
{renderResultRow("reports.compare.resultNet", net, true)}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -7,9 +7,13 @@ import type { BudgetVsActualRow } from "../../shared/types";
export interface CompareBudgetViewProps { export interface CompareBudgetViewProps {
year: number; year: number;
month: number; month: number;
/** Account (import source) filter forwarded to getBudgetVsActualData so this
* sub-tab respects the same FilterPanel selection as the rest of the Compare
* page (Issue #276). Empty/omitted = no filter. */
accountIds?: number[];
} }
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) { export default function CompareBudgetView({ year, month, accountIds }: CompareBudgetViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [rows, setRows] = useState<BudgetVsActualRow[]>([]); const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -17,7 +21,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
getBudgetVsActualData(year, month) getBudgetVsActualData(year, month, accountIds)
.then((data) => { .then((data) => {
if (!cancelled) setRows(data); if (!cancelled) setRows(data);
}) })
@ -27,7 +31,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [year, month]); }, [year, month, accountIds]);
if (error) { if (error) {
return ( return (

View file

@ -1,8 +1,10 @@
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { CategoryDelta } from "../../shared/types"; import type { CategoryDelta } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults"; import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
export interface ComparePeriodTableProps { export interface ComparePeriodTableProps {
@ -57,6 +59,16 @@ function deltaColor(value: number, higherIsBetter: boolean): string {
const STORAGE_KEY = "compare-subtotals-position"; const STORAGE_KEY = "compare-subtotals-position";
// Collapse groups keyed by category id; depth/parent mirror the render logic
// below so the hidden rows are exactly a group's indented descendants.
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
keyOf: (row) => String(row.categoryId),
depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent ?? false,
};
const COMPARE_EXPANDED_STORAGE_KEY = "reports-compare-expanded";
const COL_COUNT = 9; const COL_COUNT = 9;
export default function ComparePeriodTable({ export default function ComparePeriodTable({
@ -81,6 +93,12 @@ export default function ComparePeriodTable({
}); });
}; };
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
const groups = useCollapsibleGroups<CategoryDelta>(
COMPARE_EXPANDED_STORAGE_KEY,
COMPARE_COLLAPSE_ACCESSORS,
);
const monthPrevLabel = previousLabel; const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel; const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
@ -131,11 +149,12 @@ export default function ComparePeriodTable({
{sectionLabels[section.type]} {sectionLabels[section.type]}
</td> </td>
</tr> </tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => { {reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
const isParent = row.is_parent ?? false; const isParent = row.is_parent ?? false;
const depth = row.depth ?? 0; const depth = row.depth ?? 0;
const isTopParent = isParent && depth === 0; const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1; const isIntermediateParent = isParent && depth >= 1;
const collapsed = isTopParent && groups.isCollapsed(row);
const paddingClass = const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return ( return (
@ -158,6 +177,25 @@ export default function ComparePeriodTable({
: `${paddingClass} bg-[var(--card)]` : `${paddingClass} bg-[var(--card)]`
}`} }`}
> >
{isTopParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</button>
) : (
<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"
@ -165,6 +203,7 @@ export default function ComparePeriodTable({
/> />
{row.categoryName} {row.categoryName}
</span> </span>
)}
</td> </td>
{/* Monthly block */} {/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums"> <td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
@ -301,9 +340,22 @@ export default function ComparePeriodTable({
); );
}; };
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]"> <div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{hasGroups && (
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(rows))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
)}
<button <button
onClick={toggleSubtotals} onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors" className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"

View file

@ -0,0 +1,199 @@
// FilterPanel — unit tests (Issue #274).
//
// NOTE: This project has no @testing-library/react / jsdom configured (see
// PriceFetchControl.test.tsx). FilterPanel, unlike most components with
// their own test file, has NO internal state or hooks besides `useTranslation`
// — mocked below into a plain function — so it can be invoked directly as a
// plain function (bypassing JSX/React.createElement's usual `<FilterPanel />`
// call site) and its return value (a plain React-element object tree, since
// JSX creation never touches the DOM) walked directly. This gives genuine
// coverage of the shipped render + selection-wiring code, not a
// re-implementation of its logic in the test.
import { describe, it, expect, vi } from "vitest";
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => key,
i18n: { language: "fr" },
})),
}));
vi.mock("lucide-react", () => ({
Inbox: () => null,
}));
import FilterPanel, { toggleAccountId } from "./FilterPanel";
import type { FilterPanelProps } from "./FilterPanel";
import type { ImportSource } from "../../shared/types";
// ---------------------------------------------------------------------------
// Minimal React-element tree walker (no renderer/DOM involved)
// ---------------------------------------------------------------------------
interface ElementNode {
type: unknown;
props: Record<string, unknown> & { children?: unknown };
}
type FlatNode = ElementNode | string | number;
function isElementNode(x: unknown): x is ElementNode {
return typeof x === "object" && x !== null && "props" in x;
}
function flatten(node: unknown): FlatNode[] {
if (Array.isArray(node)) return node.flatMap(flatten);
if (node === null || node === undefined || typeof node === "boolean") return [];
if (typeof node === "string" || typeof node === "number") return [node];
if (isElementNode(node)) return [node, ...flatten(node.props.children)];
return [];
}
function findCheckboxes(root: unknown): ElementNode[] {
return flatten(root).filter(
(n): n is ElementNode => isElementNode(n) && n.props.type === "checkbox",
);
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SOURCE_CHEQUING: ImportSource = {
id: 1,
name: "Compte chèque Desjardins",
date_format: "YYYY-MM-DD",
delimiter: ",",
encoding: "utf-8",
column_mapping: "{}",
skip_lines: 0,
has_header: true,
created_at: "2026-01-01",
updated_at: "2026-01-01",
};
const SOURCE_VISA: ImportSource = {
...SOURCE_CHEQUING,
id: 2,
name: "Visa Desjardins",
};
function renderPanel(overrides: Partial<FilterPanelProps> = {}) {
const onAccountIdsChange = vi.fn();
const props: FilterPanelProps = {
temporalControl: "TEMPORAL_CONTROL_MARKER",
accountIds: [],
onAccountIdsChange,
accounts: [SOURCE_CHEQUING, SOURCE_VISA],
...overrides,
};
const element = FilterPanel(props);
return { element, onAccountIdsChange };
}
// ---------------------------------------------------------------------------
// toggleAccountId — pure selection logic
// ---------------------------------------------------------------------------
describe("toggleAccountId", () => {
it("adds an absent id to an empty list", () => {
expect(toggleAccountId([], 1)).toEqual([1]);
});
it("appends an absent id after the existing ones", () => {
expect(toggleAccountId([1, 2], 3)).toEqual([1, 2, 3]);
});
it("removes a present id, preserving the order of the remaining ids", () => {
expect(toggleAccountId([1, 2, 3], 2)).toEqual([1, 3]);
});
it("removing the only selected id returns an empty array (= all accounts)", () => {
expect(toggleAccountId([1], 1)).toEqual([]);
});
it("does not mutate the input array", () => {
const input = [1, 2];
toggleAccountId(input, 3);
expect(input).toEqual([1, 2]);
});
});
// ---------------------------------------------------------------------------
// FilterPanel — render
// ---------------------------------------------------------------------------
describe("FilterPanel — render", () => {
it("renders the injected temporalControl as-is", () => {
const { element } = renderPanel();
expect(flatten(element)).toContain("TEMPORAL_CONTROL_MARKER");
});
it("renders one checkbox per account", () => {
const { element } = renderPanel();
expect(findCheckboxes(element)).toHaveLength(2);
});
it("renders each account's name as visible text", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("Compte chèque Desjardins");
expect(text).toContain("Visa Desjardins");
});
it("checks exactly the accounts present in accountIds", () => {
const { element } = renderPanel({ accountIds: [2] });
const checkboxes = findCheckboxes(element);
expect(checkboxes.map((c) => c.props.checked)).toEqual([false, true]);
});
it("checks no checkbox when accountIds is empty (= all accounts, no explicit filter)", () => {
const { element } = renderPanel({ accountIds: [] });
const checkboxes = findCheckboxes(element);
expect(checkboxes.every((c) => c.props.checked === false)).toBe(true);
});
it("renders zero checkboxes when accounts is empty, without crashing", () => {
const { element } = renderPanel({ accounts: [] });
expect(findCheckboxes(element)).toHaveLength(0);
});
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("reports.filters.accounts.label");
expect(text).toContain("reports.filters.accounts.hint");
});
});
// ---------------------------------------------------------------------------
// FilterPanel — selection wiring
// ---------------------------------------------------------------------------
describe("FilterPanel — selection wiring", () => {
it("checking an unselected account calls onAccountIdsChange with it added", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
const checkboxes = findCheckboxes(element);
// accounts[1] = SOURCE_VISA (id 2), currently unselected
(checkboxes[1].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenCalledWith([1, 2]);
});
it("unchecking the only selected account calls onAccountIdsChange with an empty array", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
const checkboxes = findCheckboxes(element);
// accounts[0] = SOURCE_CHEQUING (id 1), currently selected
(checkboxes[0].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenCalledWith([]);
});
it("checking a second account preserves the first (multi-select)", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [] });
const checkboxes = findCheckboxes(element);
(checkboxes[0].props.onChange as () => void)();
(checkboxes[1].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenNthCalledWith(1, [1]);
expect(onAccountIdsChange).toHaveBeenNthCalledWith(2, [2]);
});
});

View file

@ -0,0 +1,93 @@
// FilterPanel — shared filter bar for the report pages (Issue #274, epic #260).
//
// Design: a "slot" component. Each report page owns its own temporal state
// (Compare = a local reducer, Trends/ByCategory = the `sources` query string
// via useReportsPeriod, Budget = a plain year) and renders its own control
// (PeriodSelector / CompareReferenceMonthPicker / YearNavigator / …), passed
// in as-is via `temporalControl`. FilterPanel does NOT own a `temporalMode`
// enum and does not know which control it is rendering — an earlier design
// unifying temporal state behind such an enum was rejected specifically to
// avoid mixing state sources (see spec-plan-rapports-uniformes-suite.md).
// The only state this component actually owns/shares across report pages is
// the account (import source) filter.
//
// `accounts`/`accountIds` refer to `ImportSource` rows (bank statement import
// sources, e.g. "Compte chèque Desjardins") — a disjoint id space from the
// Bilan module's `balance_accounts` ("Compte" in that module's own UI). The
// copy in this component deliberately says "sources" everywhere, never
// "comptes"/"accounts", so the two concepts never read as the same filter.
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Inbox } from "lucide-react";
import type { ImportSource } from "../../shared/types";
export interface FilterPanelProps {
/** The page's own temporal control, rendered as-is. */
temporalControl: ReactNode;
/** Selected import source ids. Empty array = no filter (all sources). */
accountIds: number[];
onAccountIdsChange: (accountIds: number[]) => void;
/** Import sources available to filter on. */
accounts: ImportSource[];
}
/**
* Toggles `id` in/out of `accountIds` (add if absent, remove if present),
* preserving the relative order of the remaining ids. Pure and exported so
* the selection logic is unit-testable without rendering the component.
*/
export function toggleAccountId(accountIds: number[], id: number): number[] {
return accountIds.includes(id)
? accountIds.filter((existing) => existing !== id)
: [...accountIds, id];
}
export default function FilterPanel({
temporalControl,
accountIds,
onAccountIdsChange,
accounts,
}: FilterPanelProps) {
const { t } = useTranslation();
const accountsLabel = t("reports.filters.accounts.label");
return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4 flex flex-wrap items-start gap-4">
<div>{temporalControl}</div>
<div className="flex flex-col gap-1.5">
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-[var(--muted-foreground)]">
<Inbox size={14} />
{accountsLabel}
</span>
<div
className="flex flex-wrap gap-x-4 gap-y-1.5"
role="group"
aria-label={accountsLabel}
>
{accounts.map((account) => (
<label
key={account.id}
className="flex items-center gap-1.5 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={accountIds.includes(account.id)}
onChange={() =>
onAccountIdsChange(toggleAccountId(accountIds, account.id))
}
className="accent-[var(--primary)]"
/>
{account.name}
</label>
))}
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{t("reports.filters.accounts.hint")}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import type { BudgetVsActualRow } from "../../shared/types";
import { computeResults, sumLeaves } from "./budgetVsActualResults";
// Minimal leaf factory. Amounts arrive from getBudgetVsActualData already in
// the per-type sign convention: expense actual/budget are negative (raw,
// unsigned SUM(transactions.amount) — expenses are stored negative — and the
// user-entered budget negated to match via signFor); income/transfer stay
// however they naturally sum (positive for income, signed net for transfer).
function leaf(
type: "expense" | "income" | "transfer",
monthActual: number,
monthBudget: number,
ytdActual = monthActual,
ytdBudget = monthBudget,
): BudgetVsActualRow {
return {
category_id: 1,
category_name: type,
category_color: "#000",
category_type: type,
parent_id: null,
is_parent: false,
monthActual,
monthBudget,
monthVariation: monthActual - monthBudget,
monthVariationPct: monthBudget !== 0 ? (monthActual - monthBudget) / Math.abs(monthBudget) : null,
ytdActual,
ytdBudget,
ytdVariation: ytdActual - ytdBudget,
ytdVariationPct: ytdBudget !== 0 ? (ytdActual - ytdBudget) / Math.abs(ytdBudget) : null,
};
}
describe("computeResults — income-statement roll-up (Issue #277)", () => {
it("resultBefore = income + expense (expense already signed negative)", () => {
const r = computeResults([
leaf("income", 3000, 2800),
leaf("expense", -1800, -2000), // spent 1800, budgeted to spend 2000
leaf("transfer", 0, 0),
]);
// Operating result: 3000 + (-1800) = 1200 actual; 2800 + (-2000) = 800 budgeted.
expect(r.resultBefore.monthActual).toBe(1200);
expect(r.resultBefore.monthBudget).toBe(800);
expect(r.resultBefore.monthVariation).toBe(400);
// Balanced transfer (0) → net equals the operating result.
expect(r.resultNet.monthActual).toBe(1200);
expect(r.hasTransfers).toBe(true);
});
it("a non-zero transfer moves resultNet away from resultBefore", () => {
const r = computeResults([
leaf("income", 4000, 4000),
leaf("expense", -3000, -3000),
leaf("transfer", -200, 0), // a single categorised leg
]);
expect(r.resultBefore.monthActual).toBe(1000);
// Net folds the residual transfer in: 1000 + (200) = 800.
expect(r.resultNet.monthActual).toBe(800);
});
it("a deficit yields a negative net result and no transfer line", () => {
const r = computeResults([leaf("income", 2000, 2000), leaf("expense", -2500, -2400)]);
expect(r.resultNet.monthActual).toBe(-500);
expect(r.hasTransfers).toBe(false);
});
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
const parent = { ...leaf("expense", -999, -999), is_parent: true };
const r = computeResults([parent, leaf("expense", -100, -80)]);
expect(r.expense.monthActual).toBe(-100);
});
it("carries the YTD figures through the same arithmetic", () => {
const r = computeResults([
leaf("income", 100, 100, 1200, 1150),
leaf("expense", -60, -60, -700, -650),
]);
expect(r.resultBefore.ytdActual).toBe(500);
expect(r.resultBefore.ytdBudget).toBe(500);
});
it("sumLeaves adds only leaves", () => {
const t = sumLeaves([
leaf("expense", -100, -50),
{ ...leaf("expense", -999, -999), is_parent: true },
]);
expect(t.monthActual).toBe(-100);
expect(t.monthBudget).toBe(-50);
});
});

View file

@ -0,0 +1,95 @@
import type { BudgetVsActualRow } from "../../shared/types";
export type SectionType = "expense" | "income" | "transfer";
/** Aggregate of the 6 budget-vs-actual figures across a set of leaf rows. */
export interface Totals {
monthActual: number;
monthBudget: number;
monthVariation: number;
ytdActual: number;
ytdBudget: number;
ytdVariation: number;
}
const ZERO: Totals = {
monthActual: 0,
monthBudget: 0,
monthVariation: 0,
ytdActual: 0,
ytdBudget: 0,
ytdVariation: 0,
};
/** Sum every non-subtotal (leaf) row into a single Totals. */
export function sumLeaves(rows: BudgetVsActualRow[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(acc, r) => ({
monthActual: acc.monthActual + r.monthActual,
monthBudget: acc.monthBudget + r.monthBudget,
monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ ...ZERO },
);
}
/** Variation as a fraction of budget (null when budget is 0 mirrors the
* row-level `*VariationPct` fields computed in `getBudgetVsActualData`). */
export function pct(variation: number, budget: number): number | null {
return budget !== 0 ? variation / Math.abs(budget) : null;
}
/** Component-wise a + b across the 6 figures. */
function add(a: Totals, b: Totals): Totals {
return {
monthActual: a.monthActual + b.monthActual,
monthBudget: a.monthBudget + b.monthBudget,
monthVariation: a.monthVariation + b.monthVariation,
ytdActual: a.ytdActual + b.ytdActual,
ytdBudget: a.ytdBudget + b.ytdBudget,
ytdVariation: a.ytdVariation + b.ytdVariation,
};
}
export interface BudgetVsActualResults {
income: Totals;
expense: Totals;
transfer: Totals;
/** Income + expense: the operating result, before transfers. */
resultBefore: Totals;
/** resultBefore + transfers: the bottom-line total. */
resultNet: Totals;
hasTransfers: boolean;
}
/**
* Income-statement roll-up for the budget-vs-actual report (Issue #277).
*
* Mirrors `compareResults.computeResults`, with one key sign difference: BVA
* amounts already arrive signed the "accounting" way rather than ABS'd to
* positive magnitudes. `getBudgetVsActualData` computes `monthActual`/
* `ytdActual` as the raw (unsigned-by-us) `SUM(transactions.amount)` negative
* for expense categories, since expense transactions are stored as negative
* amounts throughout this app (see reportService/dashboardService `amount < 0`
* conventions) and negates the user-entered (positive) budget figure to
* match via `signFor`. So expense totals here are already 0, and the
* operating result is a straight ADD of income + expense (not a subtraction
* like the real-vs-real compare, whose expense figures are ABS'd positive
* magnitudes). The net just adds the transfer total on top.
*/
export function computeResults(rows: BudgetVsActualRow[]): BudgetVsActualResults {
const byType = (type: SectionType): Totals =>
sumLeaves(rows.filter((r) => r.category_type === type));
const income = byType("income");
const expense = byType("expense");
const transfer = byType("transfer");
const resultBefore = add(income, expense);
const resultNet = add(resultBefore, transfer);
const hasTransfers = rows.some((r) => r.category_type === "transfer" && !r.is_parent);
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
}

View file

@ -1,12 +1,41 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults"; import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
import type { CategoryOverTimeData } from "../../shared/types"; import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
function makeData( /** Builds an id-keyed leaf row; `monthly` is index-aligned to the months list. */
data: CategoryOverTimeData["data"], function leaf(
types: Record<string, "income" | "expense" | "transfer">, categoryId: number | null,
): CategoryOverTimeData { categoryName: string,
return { categories: Object.keys(types), data, colors: {}, categoryIds: {}, types }; monthly: number[],
category_type: "income" | "expense" | "transfer",
extra: Partial<OverTimeRow> = {},
): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type,
...extra,
};
}
/** Wraps a month list + id-keyed tree into a CategoryOverTimeData. The pivot
* fields are irrelevant here computeOverTimeResults reads `data.tree`, using
* `data.data` only for the (ordered) month list. */
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
return {
categories: [],
data: months.map((month) => ({ month })),
colors: {},
categoryIds: {},
types: {},
tree,
};
} }
describe("OVER_TIME_TYPE_ORDER", () => { describe("OVER_TIME_TYPE_ORDER", () => {
@ -16,19 +45,21 @@ describe("OVER_TIME_TYPE_ORDER", () => {
}); });
describe("computeOverTimeResults", () => { describe("computeOverTimeResults", () => {
it("groups categories into ordered sections with per-month + total subtotals", () => { it("groups leaves into ordered sections with per-month + total subtotals", () => {
const data = makeData( const data = makeData(
["2025-01", "2025-02"],
[ [
{ month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 }, leaf(2, "Rent", [1000, 1000], "expense"),
{ month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 }, leaf(4, "Savings", [200, 200], "transfer"),
leaf(1, "Salary", [3000, 3000], "income"),
leaf(3, "Groceries", [500, 700], "expense"),
], ],
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
); );
const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]); const r = computeOverTimeResults(data);
expect(r.months).toEqual(["2025-01", "2025-02"]); expect(r.months).toEqual(["2025-01", "2025-02"]);
// Income-first ordering, regardless of the input category order. // Income-first ordering, regardless of the input row order.
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]); expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]);
const income = r.sections.find((s) => s.type === "income")!; const income = r.sections.find((s) => s.type === "income")!;
@ -37,7 +68,7 @@ describe("computeOverTimeResults", () => {
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 }); expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 });
expect(income.total).toBe(6000); expect(income.total).toBe(6000);
expect(expense.categories).toEqual(["Rent", "Groceries"]); expect(expense.rows.map((row) => row.categoryName)).toEqual(["Rent", "Groceries"]);
expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 }); expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 });
expect(expense.total).toBe(3200); expect(expense.total).toBe(3200);
expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 }); expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 });
@ -46,14 +77,16 @@ describe("computeOverTimeResults", () => {
it("computes net (income expense + transfer) and before-transfers per month", () => { it("computes net (income expense + transfer) and before-transfers per month", () => {
const data = makeData( const data = makeData(
["2025-01", "2025-02"],
[ [
{ month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 }, leaf(1, "Salary", [3000, 3000], "income"),
{ month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 }, leaf(2, "Rent", [1000, 1000], "expense"),
leaf(3, "Groceries", [500, 700], "expense"),
leaf(4, "Savings", [200, 200], "transfer"),
], ],
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
); );
const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]); const r = computeOverTimeResults(data);
expect(r.hasTransfers).toBe(true); expect(r.hasTransfers).toBe(true);
// before = income expense // before = income expense
@ -66,11 +99,11 @@ describe("computeOverTimeResults", () => {
it("collapses net to before-transfers when there are no transfers", () => { it("collapses net to before-transfers when there are no transfers", () => {
const data = makeData( const data = makeData(
[{ month: "2025-01", Salary: 2000, Rent: 2500 }], ["2025-01"],
{ Salary: "income", Rent: "expense" }, [leaf(1, "Salary", [2000], "income"), leaf(2, "Rent", [2500], "expense")],
); );
const r = computeOverTimeResults(data, ["Salary", "Rent"]); const r = computeOverTimeResults(data);
expect(r.hasTransfers).toBe(false); expect(r.hasTransfers).toBe(false);
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]); expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
@ -80,24 +113,27 @@ describe("computeOverTimeResults", () => {
expect(r.net.total).toBe(-500); expect(r.net.total).toBe(-500);
}); });
it("defaults an untyped category (e.g. Other) to expense", () => { it("defaults a leaf with an unknown type to the expense section (defensive)", () => {
const data = makeData( // The tree normally stamps a valid type on every leaf; the reducer still
[{ month: "2025-01", Salary: 1000, Other: 300 }], // guards against a stray value by folding it into expenses.
{ Salary: "income" }, // Other has no type entry const untyped = {
); ...leaf(9, "Other", [300], "expense"),
category_type: undefined as unknown as "expense",
};
const data = makeData(["2025-01"], [leaf(1, "Salary", [1000], "income"), untyped]);
const r = computeOverTimeResults(data, ["Salary", "Other"]); const r = computeOverTimeResults(data);
const expense = r.sections.find((s) => s.type === "expense")!; const expense = r.sections.find((s) => s.type === "expense")!;
expect(expense.categories).toEqual(["Other"]); expect(expense.rows.map((row) => row.categoryName)).toEqual(["Other"]);
expect(expense.total).toBe(300); expect(expense.total).toBe(300);
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 300 expect(r.net.monthly["2025-01"]).toBe(700); // 1000 300
}); });
it("drops empty sections and still nets a lone transfer section", () => { it("drops empty sections and still nets a lone transfer section", () => {
const data = makeData([{ month: "2025-01", Move: 100 }], { Move: "transfer" }); const data = makeData(["2025-01"], [leaf(1, "Move", [100], "transfer")]);
const r = computeOverTimeResults(data, ["Move"]); const r = computeOverTimeResults(data);
expect(r.sections.map((s) => s.type)).toEqual(["transfer"]); expect(r.sections.map((s) => s.type)).toEqual(["transfer"]);
expect(r.hasTransfers).toBe(true); expect(r.hasTransfers).toBe(true);
@ -105,29 +141,51 @@ describe("computeOverTimeResults", () => {
expect(r.net.monthly["2025-01"]).toBe(100); expect(r.net.monthly["2025-01"]).toBe(100);
}); });
it("only counts the categories passed as visible (hidden ones excluded)", () => { it("keeps homonym categories of different types apart (Résultat net correct) — Issue #264", () => {
// Two DIFFERENT categories both named "Divers": one income, one expense.
// A name-keyed pivot would merge them into a single cell and a single type;
// the id-keyed tree keeps them distinct, so the net stays correct.
const data = makeData( const data = makeData(
[{ month: "2025-01", Salary: 2000, Rent: 500, Hidden: 999 }], ["2025-01"],
{ Salary: "income", Rent: "expense", Hidden: "expense" }, [leaf(1, "Divers", [1000], "income"), leaf(2, "Divers", [300], "expense")],
); );
const r = computeOverTimeResults(data, ["Salary", "Rent"]); // Hidden filtered out upstream const r = computeOverTimeResults(data);
const income = r.sections.find((s) => s.type === "income")!;
const expense = r.sections.find((s) => s.type === "expense")!;
expect(income.total).toBe(1000);
expect(expense.total).toBe(300);
// Net = 1000 300 = 700, NOT 0 (which a name collision would produce).
expect(r.net.monthly["2025-01"]).toBe(700);
});
it("sums leaves only — a parent subtotal row is never double-counted — Issue #264", () => {
// The tree carries both a parent subtotal (is_parent) and its leaves. The
// reducer must add up leaves only, or the parent's 800 would be counted twice.
const parent = { ...leaf(10, "Dépenses", [800], "expense"), is_parent: true };
const child1 = { ...leaf(11, "Épicerie", [500], "expense"), parent_id: 10, depth: 1 };
const child2 = { ...leaf(12, "Restaurant", [300], "expense"), parent_id: 10, depth: 1 };
const income = leaf(1, "Salary", [2000], "income");
const data = makeData(["2025-01"], [income, parent, child1, child2]);
const r = computeOverTimeResults(data);
const expense = r.sections.find((s) => s.type === "expense")!; const expense = r.sections.find((s) => s.type === "expense")!;
expect(expense.total).toBe(500); // 500 + 300 = 800 (the parent's own 800 subtotal is excluded).
expect(r.net.monthly["2025-01"]).toBe(1500); // 2000 500 expect(expense.total).toBe(800);
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Épicerie", "Restaurant"]);
// Net = 2000 800 = 1200, not 2000 1600.
expect(r.net.monthly["2025-01"]).toBe(1200);
}); });
it("treats a category absent from a month as zero", () => { it("treats a category absent from a month as zero", () => {
const data = makeData( const data = makeData(
[ ["2025-01", "2025-02"],
{ month: "2025-01", Salary: 1000 }, [leaf(1, "Salary", [1000, 1000], "income"), leaf(2, "Bonus", [0, 500], "income")],
{ month: "2025-02", Salary: 1000, Bonus: 500 },
],
{ Salary: "income", Bonus: "income" },
); );
const r = computeOverTimeResults(data, ["Salary", "Bonus"]); const r = computeOverTimeResults(data);
const income = r.sections.find((s) => s.type === "income")!; const income = r.sections.find((s) => s.type === "income")!;
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 }); expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });

View file

@ -1,12 +1,12 @@
import type { CategoryOverTimeData, CategoryOverTimeItem } from "../../shared/types"; import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
export type OverTimeType = "income" | "expense" | "transfer"; export type OverTimeType = "income" | "expense" | "transfer";
/** /**
* Income-statement reading order: revenue first, then expenses, then transfers, * Income-statement reading order: revenue first, then expenses, then transfers,
* so the "net result" row reads naturally at the bottom (Income Expenses + * so the "net result" row reads naturally at the bottom (Income Expenses +
* Transfers). Note this is income-first, unlike the compare report's * Transfers). Matches the compare report's `COMPARE_TYPE_ORDER`, which is also
* `COMPARE_TYPE_ORDER` (expense-first) see issue #256. * income-first since issue #253 (the earlier expense-first note is obsolete).
*/ */
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = { export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
income: 0, income: 0,
@ -22,10 +22,18 @@ export interface OverTimeSeries {
total: number; total: number;
} }
/** One rendered section: a type, its member categories, and per-month + total subtotals. */ /**
export interface OverTimeSection extends OverTimeSeries { * One rendered section: a type, its member leaf rows (id-keyed, from the tree),
* and per-month + total subtotals. `rows` are the actual leaves in the section
* (never subtotal rows), each carrying its own index-aligned `monthly[]`.
*/
export interface OverTimeSection {
type: OverTimeType; type: OverTimeType;
categories: string[]; rows: OverTimeRow[];
/** month (YYYY-MM) -> section subtotal */
monthly: Record<string, number>;
/** sum across every month */
total: number;
} }
export interface OverTimeAnalysis { export interface OverTimeAnalysis {
@ -41,60 +49,53 @@ export interface OverTimeAnalysis {
beforeTransfers: OverTimeSeries; beforeTransfers: OverTimeSeries;
} }
/** Reads a numeric category cell off a pivot row, treating anything non-numeric as 0. */
function cellValue(item: CategoryOverTimeItem | undefined, category: string): number {
const v = (item as Record<string, unknown> | undefined)?.[category];
return typeof v === "number" ? v : 0;
}
/** /**
* Pure reducer that turns the category-over-time pivot into an "income statement" * Pure reducer that turns the category-over-time report into an "income
* shape: per-type sections with per-month subtotals, plus the net-result rows. * statement" shape: per-type sections with per-month subtotals, plus the
* net-result rows.
* *
* Values coming from `getCategoryOverTime` are positive magnitudes (`ABS(SUM())` * Consumes the **id-keyed tree** (`data.tree`) NOT the name-keyed pivot so
* in SQL), so income/expense/transfer subtotals are all 0, and the net is * two homonym categories of different types never collide: each leaf carries its
* `income expense + transfer`. A category whose type is unknown (e.g. the * own `category_id`-resolved `category_type`, and the subtotals + result lines
* "Other" bucket that aggregates non-top-N categories) defaults to `expense`, * are summed over the tree's LEAVES only (never its `is_parent` subtotal rows,
* mirroring the `COALESCE(c.type, 'expense')` used by the service. * which would double-count their own descendants). Leaf `monthly[]` values are
* positive magnitudes (`ABS(SUM())` in SQL), so income/expense/transfer
* subtotals are all 0 and the net is `income expense + transfer`.
* *
* Extracted from `CategoryOverTimeTable` and unit-tested independently the * The tree is already ordered income expense transfer, so section ordering
* project has no React render harness, so the arithmetic lives in a pure module. * falls out of the leaves' own `category_type`.
*/ */
export function computeOverTimeResults( export function computeOverTimeResults(data: CategoryOverTimeData): OverTimeAnalysis {
data: CategoryOverTimeData,
visibleCategories: string[],
): OverTimeAnalysis {
const months = data.data.map((d) => d.month); const months = data.data.map((d) => d.month);
const itemByMonth = new Map<string, CategoryOverTimeItem>();
for (const item of data.data) itemByMonth.set(item.month, item);
const typeOf = (cat: string): OverTimeType => { // Leaves only — subtotals (`is_parent`) are excluded so a parent group can
const t = data.types[cat]; // never double-count against its own descendants.
const typeOf = (row: OverTimeRow): OverTimeType => {
const t = row.category_type;
return t === "income" || t === "transfer" ? t : "expense"; return t === "income" || t === "transfer" ? t : "expense";
}; };
const byType: Record<OverTimeType, OverTimeRow[]> = { income: [], expense: [], transfer: [] };
// Bucket visible categories by type, preserving their incoming (magnitude) order. for (const row of data.tree) {
const byType: Record<OverTimeType, string[]> = { income: [], expense: [], transfer: [] }; if (!row.is_parent) byType[typeOf(row)].push(row);
for (const cat of visibleCategories) byType[typeOf(cat)].push(cat); }
const buildSection = (type: OverTimeType): OverTimeSection => { const buildSection = (type: OverTimeType): OverTimeSection => {
const categories = byType[type]; const rows = byType[type];
const monthly: Record<string, number> = {}; const monthly: Record<string, number> = {};
let total = 0; let total = 0;
for (const month of months) { months.forEach((month, i) => {
const item = itemByMonth.get(month); const sum = rows.reduce((s, row) => s + (row.monthly[i] ?? 0), 0);
const sum = categories.reduce((s, cat) => s + cellValue(item, cat), 0);
monthly[month] = sum; monthly[month] = sum;
total += sum; total += sum;
} });
return { type, categories, monthly, total }; return { type, rows, monthly, total };
}; };
const income = buildSection("income"); const income = buildSection("income");
const expense = buildSection("expense"); const expense = buildSection("expense");
const transfer = buildSection("transfer"); const transfer = buildSection("transfer");
const hasTransfers = transfer.categories.length > 0; const hasTransfers = transfer.rows.length > 0;
const net: OverTimeSeries = { monthly: {}, total: 0 }; const net: OverTimeSeries = { monthly: {}, total: 0 };
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 }; const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
@ -108,7 +109,7 @@ export function computeOverTimeResults(
} }
const sections = [income, expense, transfer] const sections = [income, expense, transfer]
.filter((s) => s.categories.length > 0) .filter((s) => s.rows.length > 0)
.sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]); .sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]);
return { months, sections, hasTransfers, net, beforeTransfers }; return { months, sections, hasTransfers, net, beforeTransfers };

View file

@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { visibleRows } from "../../utils/collapsibleRows";
import { computeOverTimeResults } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
} from "./overTimeTableModel";
type RowType = "income" | "expense" | "transfer";
/** Builds one id-keyed tree row; `monthly` is index-aligned to the months list. */
function mkRow(
categoryId: number,
categoryName: string,
monthly: number[],
category_type: RowType,
extra: Partial<OverTimeRow> = {},
): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type,
...extra,
};
}
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
return {
categories: [],
data: months.map((month) => ({ month })),
colors: {},
categoryIds: {},
types: {},
tree,
};
}
const MONTHS = ["2025-01", "2025-02"];
// A realistic parent-first depth-first tree (income → expense → transfer):
// Revenus (parent, id 1)
// Paie (leaf, id 11)
// Bonus (leaf, id 12)
// Dépenses (parent, id 2)
// Épicerie (leaf, id 21)
// Resto (leaf, id 22)
// Loyer (top-level leaf, id 23)
// Épargne (transfer leaf, id 3)
function buildTree(): OverTimeRow[] {
return [
mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }),
mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 2, depth: 1 }),
mkRow(22, "Resto", [100, 200], "expense", { parent_id: 2, depth: 1 }),
mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
];
}
const acc = OVERTIME_COLLAPSE_ACCESSORS;
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */
const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
describe("overTimeTableModel — storage key", () => {
it("uses a trends-specific key, distinct from the comparable tables", () => {
expect(OVERTIME_EXPANDED_STORAGE_KEY).toBe("reports-trends-expanded");
expect(OVERTIME_EXPANDED_STORAGE_KEY).not.toBe("reports-compare-expanded");
});
});
describe("overTimeTableModel — collapse accessors", () => {
it("keys by category id and reads the snake_case hierarchy block", () => {
const parent = buildTree()[0];
const leaf = buildTree()[1];
expect(acc.keyOf(parent)).toBe("1");
expect(acc.depthOf(leaf)).toBe(1);
expect(acc.isParent(parent)).toBe(true);
expect(acc.isParent(leaf)).toBe(false);
});
});
describe("overTimeTableModel.groupOverTimeSections", () => {
it("splits the tree into ordered sections carrying the FULL hierarchy (parents + leaves)", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
// Income-first, transfer split out for interleaving.
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
expect(transferSection?.type).toBe("transfer");
// Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
const income = nonTransferSections.find((s) => s.type === "income")!;
expect(income.rows.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(income.rows[0].is_parent).toBe(true);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
expect(expense.rows.map((r) => r.categoryName)).toEqual([
"Dépenses",
"Épicerie",
"Resto",
"Loyer",
]);
expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
});
it("keeps subtotals as the reducer's leaf sums — a parent is never double-counted", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
// Paie 6000 + Bonus 500 = 6500 (the Revenus parent's own 6500 is NOT added again).
expect(income.total).toBe(6500);
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });
const expense = nonTransferSections.find((s) => s.type === "expense")!;
// Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded).
expect(expense.total).toBe(3300);
});
it("drops empty sections: no transfers → transferSection is null", () => {
const tree = buildTree().filter((r) => r.category_type !== "transfer");
const data = makeData(MONTHS, tree);
const analysis = computeOverTimeResults(data);
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
expect(transferSection).toBeNull();
expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
});
});
describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
it("is collapsed by default: only depth-0 rows survive an empty expanded set", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
// Revenus stays (its own subtotal row); Paie/Bonus are folded away.
expect(visIncome.map((r) => r.categoryName)).toEqual(["Revenus"]);
const expense = nonTransferSections.find((s) => s.type === "expense")!;
const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
// Dépenses folds its children; the top-level leaf Loyer always stays.
expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]);
});
it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
const data = makeData(MONTHS, buildTree());
const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
expect(visible.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]);
expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
});
it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
const data = makeData(MONTHS, buildTree());
const analysis = computeOverTimeResults(data);
const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
const income = nonTransferSections.find((s) => s.type === "income")!;
// Figures come from the raw tree (never the visible rows), so they hold in
// every collapse state.
expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income 3300 expense
expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
expect(income.total).toBe(6500);
// Folding vs expanding a group only changes how many rows are visible.
const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"])));
expect(collapsed.length).toBeLessThan(expanded.length);
// The subtotal the table shows is identical in both states.
expect(income.total).toBe(6500);
});
});

View file

@ -0,0 +1,89 @@
import type { OverTimeRow } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults";
/**
* Render-model helpers for the hierarchical trends table (issue #265). Kept in a
* pure module the project has no React render harness, so the grouping and
* result-interleaving logic is unit-tested here while `CategoryOverTimeTable`
* stays a thin renderer.
*/
/**
* Collapse groups keyed by category id; depth/parent mirror the tree the render
* indents by, so a collapsed group hides exactly its indented descendants. The
* `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these
* accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged.
*/
export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors<OverTimeRow> = {
keyOf: (row) => String(row.categoryId),
depthOf: (row) => row.depth,
isParent: (row) => row.is_parent,
};
/** Distinct localStorage key so the trends expansion state never collides with
* the comparable tables' `reports-compare-expanded` (issue #265). */
export const OVERTIME_EXPANDED_STORAGE_KEY = "reports-trends-expanded";
/**
* One rendered section: its type, the FULL hierarchy rows of that type (parents
* + leaves, parent-first depth-first straight off `data.tree`), and the
* leaf-summed per-month + total subtotals carried over from the reducer.
*
* `rows` carries the parents (unlike `OverTimeSection.rows`, which is leaves
* only) so the table can render the hierarchy and collapse it; `monthly`/`total`
* stay the reducer's leaf sums, so they are unaffected by which rows are folded
* away the "collapse is purely visual" invariant.
*/
export interface OverTimeRenderSection {
type: OverTimeType;
rows: OverTimeRow[];
/** month (YYYY-MM) -> section subtotal (leaf-summed, collapse-invariant). */
monthly: Record<string, number>;
/** sum across every month (leaf-summed, collapse-invariant). */
total: number;
}
const typeOf = (row: OverTimeRow): OverTimeType => {
const t = row.category_type;
return t === "income" || t === "transfer" ? t : "expense";
};
/**
* Splits the id-keyed tree into render sections, driven by the reducer's own
* (ordered, non-empty, income expense transfer) `analysis.sections` so the
* ordering and the subtotals match `computeOverTimeResults` exactly. Each
* section is given the FULL hierarchy rows of its type (from `tree`), while its
* subtotals stay the reducer's leaf sums.
*
* Returns the non-transfer sections and the transfer section separately so the
* caller can interleave the "result before transfers" line between them
* (parity with `ComparePeriodTable`).
*/
export function groupOverTimeSections(
analysis: OverTimeAnalysis,
tree: OverTimeRow[],
): {
nonTransferSections: OverTimeRenderSection[];
transferSection: OverTimeRenderSection | null;
} {
// Full hierarchy rows (parents + leaves) bucketed by type, tree order kept.
const rowsByType: Record<OverTimeType, OverTimeRow[]> = {
income: [],
expense: [],
transfer: [],
};
for (const row of tree) rowsByType[typeOf(row)].push(row);
const sections: OverTimeRenderSection[] = analysis.sections.map((s) => ({
type: s.type,
rows: rowsByType[s.type],
monthly: s.monthly,
total: s.total,
}));
return {
nonTransferSections: sections.filter((s) => s.type !== "transfer"),
transferSection: sections.find((s) => s.type === "transfer") ?? null,
};
}

View file

@ -1,5 +1,5 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetYearRow, BudgetTemplate } from "../shared/types"; import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
import { import {
getAllActiveCategories, getAllActiveCategories,
getBudgetEntriesForYear, getBudgetEntriesForYear,
@ -11,11 +11,14 @@ import {
applyTemplate as applyTemplateSvc, applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc, deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService"; } from "../services/budgetService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
interface BudgetState { interface BudgetState {
year: number; year: number;
rows: BudgetYearRow[]; rows: BudgetYearRow[];
templates: BudgetTemplate[]; templates: BudgetTemplate[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
error: string | null; error: string | null;
@ -26,6 +29,7 @@ type BudgetAction =
| { type: "SET_SAVING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } } | { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_YEAR"; payload: number }; | { type: "SET_YEAR"; payload: number };
function initialState(): BudgetState { function initialState(): BudgetState {
@ -33,6 +37,7 @@ function initialState(): BudgetState {
year: new Date().getFullYear(), year: new Date().getFullYear(),
rows: [], rows: [],
templates: [], templates: [],
accounts: [],
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
error: null, error: null,
@ -54,6 +59,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
templates: action.payload.templates, templates: action.payload.templates,
isLoading: false, isLoading: false,
}; };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_YEAR": case "SET_YEAR":
return { ...state, year: action.payload }; return { ...state, year: action.payload };
default: default:
@ -61,13 +68,17 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
} }
} }
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; // Income-statement reading order: revenue first, then expenses, then
// transfers (Issue #278) — matches the compare/trend reports' ordering
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
export function useBudget() { export function useBudget() {
const { accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, undefined, initialState); const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number) => { const refreshData = useCallback(async (year: number, ids: 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 });
@ -76,7 +87,7 @@ export function useBudget() {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([ const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1), getActualTotalsForYear(year - 1, ids),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -364,8 +375,21 @@ export function useBudget() {
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshData(state.year); refreshData(state.year, accountIds);
}, [state.year, refreshData]); }, [state.year, accountIds, refreshData]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Compare source filters — see useTrends.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
const navigateYear = useCallback((delta: -1 | 1) => { const navigateYear = useCallback((delta: -1 | 1) => {
dispatch({ type: "SET_YEAR", payload: state.year + delta }); dispatch({ type: "SET_YEAR", payload: state.year + delta });
@ -376,7 +400,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await upsertBudgetEntry(categoryId, state.year, month, amount); await upsertBudgetEntry(categoryId, state.year, month, amount);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -386,7 +410,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const splitEvenly = useCallback( const splitEvenly = useCallback(
@ -400,7 +424,7 @@ export function useBudget() {
amounts.push(m < remainder ? base + 0.01 : base); amounts.push(m < remainder ? base + 0.01 : base);
} }
await upsertBudgetEntriesForYear(categoryId, state.year, amounts); await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -410,7 +434,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const saveTemplate = useCallback( const saveTemplate = useCallback(
@ -423,7 +447,7 @@ export function useBudget() {
.filter((r) => !r.is_parent && r.months[0] !== 0) .filter((r) => !r.is_parent && r.months[0] !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.months[0] })); .map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
await saveAsTemplateSvc(name, description, entries); await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -433,7 +457,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.rows, state.year, refreshData] [state.rows, state.year, accountIds, refreshData]
); );
const applyTemplate = useCallback( const applyTemplate = useCallback(
@ -441,7 +465,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await applyTemplateSvc(templateId, state.year, month); await applyTemplateSvc(templateId, state.year, month);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -451,7 +475,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const applyTemplateAllMonths = useCallback( const applyTemplateAllMonths = useCallback(
@ -461,7 +485,7 @@ export function useBudget() {
for (let m = 1; m <= 12; m++) { for (let m = 1; m <= 12; m++) {
await applyTemplateSvc(templateId, state.year, m); await applyTemplateSvc(templateId, state.year, m);
} }
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -471,7 +495,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const deleteTemplate = useCallback( const deleteTemplate = useCallback(
@ -479,7 +503,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await deleteTemplateSvc(templateId); await deleteTemplateSvc(templateId);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -489,7 +513,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
return { return {

View file

@ -0,0 +1,94 @@
import { useCallback, useState } from "react";
import {
type CollapseAccessors,
collapsibleKeys,
parseStoredExpanded,
serializeExpanded,
visibleRows,
} from "../utils/collapsibleRows";
export interface CollapsibleGroups<T> {
/** Filters a section's rows down to the ones visible under the current state. */
visible: (rows: T[]) => T[];
/** True when this top-level parent row is collapsed (its children are hidden). */
isCollapsed: (row: T) => boolean;
/** Flip one group between collapsed and expanded, then persist. */
toggle: (row: T) => void;
/** Expand every collapsible group found in `rows`, then persist. */
expandAll: (rows: T[]) => void;
/** Collapse every group (back to the default), then persist. */
collapseAll: () => void;
/** True when `rows` has at least one group and all of them are expanded. */
allExpanded: (rows: T[]) => boolean;
/** Number of collapsible top-level groups in `rows`. */
groupCount: (rows: T[]) => number;
}
/**
* Per-report collapse/expand state for the hierarchical comparable tables
* (issue #254). Groups are collapsed by default (issue #260): the persisted
* value is the set of *expanded* group keys, so a first-ever visit (empty set)
* shows every group collapsed with only its subtotal, while any group the user
* expands is remembered.
*
* Persistence uses localStorage, the same synchronous store the sibling
* "subtotals on top/bottom" toggle already uses in these tables. State is kept
* per report via a distinct `storageKey`.
*/
export function useCollapsibleGroups<T>(
storageKey: string,
acc: CollapseAccessors<T>,
): CollapsibleGroups<T> {
const [expanded, setExpanded] = useState<Set<string>>(() =>
parseStoredExpanded(
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
),
);
const persist = useCallback(
(next: Set<string>) => {
try {
localStorage.setItem(storageKey, serializeExpanded(next));
} catch {
// Best-effort: a write failure just means the next session falls back
// to the collapsed default.
}
setExpanded(next);
},
[storageKey],
);
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]);
const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]);
const toggle = useCallback(
(row: T) => {
const key = acc.keyOf(row);
const next = new Set(expanded);
if (next.has(key)) next.delete(key);
else next.add(key);
persist(next);
},
[expanded, acc, persist],
);
const expandAll = useCallback(
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
[acc, persist],
);
const collapseAll = useCallback(() => persist(new Set()), [persist]);
const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]);
const allExpanded = useCallback(
(rows: T[]) => {
const keys = collapsibleKeys(rows, acc);
return keys.length > 0 && keys.every((k) => expanded.has(k));
},
[expanded, acc],
);
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
}

View file

@ -1,6 +1,7 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CategoryDelta } from "../shared/types"; import type { CategoryDelta, ImportSource } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod"; import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
@ -13,6 +14,7 @@ interface State {
year: number; year: number;
month: number; month: number;
rows: CategoryDelta[]; rows: CategoryDelta[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -23,6 +25,7 @@ type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] } | { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
/** /**
@ -93,6 +96,7 @@ const initialState: State = {
year: defaultRef.year, year: defaultRef.year,
month: defaultRef.month, month: defaultRef.month,
rows: [], rows: [],
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -109,6 +113,8 @@ function reducer(state: State, action: Action): State {
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_ROWS": case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null }; return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -117,20 +123,26 @@ function reducer(state: State, action: Action): State {
} }
export function useCompare() { export function useCompare() {
const { from, to } = useReportsPeriod(); const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback( const fetch = useCallback(
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => { async (
mode: CompareMode,
subMode: CompareSubMode,
year: number,
month: number,
ids: number[],
) => {
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
const id = ++fetchIdRef.current; const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
const rows = const rows =
subMode === "mom" subMode === "mom"
? await getCompareMonthOverMonth(year, month) ? await getCompareMonthOverMonth(year, month, ids)
: await getCompareYearOverYear(year, month); : await getCompareYearOverYear(year, month, ids);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows }); dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) { } catch (e) {
@ -142,8 +154,21 @@ export function useCompare() {
); );
useEffect(() => { useEffect(() => {
fetch(state.mode, state.subMode, state.year, state.month); fetch(state.mode, state.subMode, state.year, state.month, accountIds);
}, [fetch, state.mode, state.subMode, state.year, state.month]); }, [fetch, state.mode, state.subMode, state.year, state.month, accountIds]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Transactions source filters — see useTrends.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
// Keep the reference month in sync with the URL period when the user navigates // Keep the reference month in sync with the URL period when the user navigates
// via PeriodSelector — but not on mount. The ref is seeded with the initial // via PeriodSelector — but not on mount. The ref is seeded with the initial

View file

@ -1,29 +1,63 @@
// useDashboard — scoped useReducer hook backing DashboardPage (the `/` home page).
//
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
// row, top movers and budget adherence are the exact Cartes widgets, sourced
// from `getCartesSnapshot` against a (year, month) reference period. The
// Dashboard keeps its OWN two temporal axes, neither of which is
// `useReportsPeriod` (M1/I4 deliberately left the Dashboard untouched):
// - `period` / `customDateFrom` / `customDateTo` — a flexible date range,
// feeding the two range-based transactional widgets (top-expenses bar
// chart, category-over-time trend).
// - `referenceYear` / `referenceMonth` — a single reference month, feeding
// the Cartes snapshot (KPIs + deltas, top movers, budget adherence, the
// income-vs-expenses overlay chart). Defaults to the previous complete
// month, like Cartes/Compare (`defaultReferencePeriod`).
//
// The account (import-source) filter (`accountIds`) is Dashboard-local state
// (not routed through `useReportsPeriod`), but applies to every transactional
// widget above — both temporal axes. It does NOT scope the net-worth tile
// (see below): `balance_accounts` is a disjoint concept from `import_sources`.
//
// The net-worth tile is fetched independently, once, on mount — it is not a
// transactional figure, has no relationship to `period`/`referenceMonth`, and
// must not be scoped by `accountIds`. Keeping it in its own effect makes that
// decoupling explicit in the code, not just in the UI copy.
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { import type {
DashboardPeriod, DashboardPeriod,
DashboardSummary,
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, CategoryOverTimeData,
BudgetVsActualRow, CartesSnapshot,
ImportSource,
} from "../shared/types"; } from "../shared/types";
import { import {
getDashboardSummary,
getExpensesByCategory, getExpensesByCategory,
deriveNetWorthTile,
type NetWorthTileData,
} from "../services/dashboardService"; } from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService"; import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService"; import { getAllImportSources } from "../services/transactionService";
import {
getSnapshotTotalsByDate,
listSnapshots,
listBalanceAccounts,
} from "../services/balance.service";
import { computeDateRange } from "../utils/dateRange"; import { computeDateRange } from "../utils/dateRange";
import { defaultReferencePeriod } from "../utils/referencePeriod";
interface DashboardState { interface DashboardState {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[]; categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[]; cartesSnapshot: CartesSnapshot | null;
period: DashboardPeriod; period: DashboardPeriod;
budgetYear: number;
budgetMonth: number;
customDateFrom: string; customDateFrom: string;
customDateTo: string; customDateTo: string;
referenceYear: number;
referenceMonth: number;
accountIds: number[];
accounts: ImportSource[];
netWorth: NetWorthTileData;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -34,30 +68,46 @@ type DashboardAction =
| { | {
type: "SET_DATA"; type: "SET_DATA";
payload: { payload: {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[]; categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[]; cartesSnapshot: CartesSnapshot;
}; };
} }
| { 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 } }; | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_ACCOUNT_IDS"; payload: number[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_NET_WORTH"; payload: NetWorthTileData };
const now = new Date(); const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const yearStartStr = `${now.getFullYear()}-01-01`; const yearStartStr = `${now.getFullYear()}-01-01`;
const defaultRef = defaultReferencePeriod();
const EMPTY_CATEGORY_OVER_TIME: CategoryOverTimeData = {
categories: [],
data: [],
colors: {},
categoryIds: {},
types: {},
tree: [],
};
const EMPTY_NET_WORTH: NetWorthTileData = { visible: false, total: null, asOfDate: null };
const initialState: DashboardState = { const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [], categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} }, categoryOverTime: EMPTY_CATEGORY_OVER_TIME,
budgetVsActual: [], cartesSnapshot: null,
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,
referenceYear: defaultRef.year,
referenceMonth: defaultRef.month,
accountIds: [],
accounts: [],
netWorth: EMPTY_NET_WORTH,
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -71,18 +121,28 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
case "SET_DATA": case "SET_DATA":
return { return {
...state, ...state,
summary: action.payload.summary,
categoryBreakdown: action.payload.categoryBreakdown, categoryBreakdown: action.payload.categoryBreakdown,
categoryOverTime: action.payload.categoryOverTime, categoryOverTime: action.payload.categoryOverTime,
budgetVsActual: action.payload.budgetVsActual, cartesSnapshot: action.payload.cartesSnapshot,
isLoading: false, isLoading: false,
}; };
case "SET_PERIOD": case "SET_PERIOD":
return { ...state, period: action.payload }; return { ...state, period: action.payload };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_CUSTOM_DATES": case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo }; return {
...state,
period: "custom" as DashboardPeriod,
customDateFrom: action.payload.dateFrom,
customDateTo: action.payload.dateTo,
};
case "SET_REFERENCE_PERIOD":
return { ...state, referenceYear: action.payload.year, referenceMonth: action.payload.month };
case "SET_ACCOUNT_IDS":
return { ...state, accountIds: action.payload };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_NET_WORTH":
return { ...state, netWorth: action.payload };
default: default:
return state; return state;
} }
@ -96,8 +156,9 @@ export function useDashboard() {
period: DashboardPeriod, period: DashboardPeriod,
customFrom: string | undefined, customFrom: string | undefined,
customTo: string | undefined, customTo: string | undefined,
bYear: number, refYear: number,
bMonth: number, refMonth: number,
accountIds: number[],
) => { ) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
@ -105,15 +166,17 @@ export function useDashboard() {
try { try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([ const [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([
getDashboardSummary(dateFrom, dateTo), getExpensesByCategory(dateFrom, dateTo, accountIds),
getExpensesByCategory(dateFrom, dateTo), // typeFilter "expense" (Issue #279): this chart is titled "expenses
getCategoryOverTime(dateFrom, dateTo), // over time" but previously aggregated every category type — a
getBudgetVsActualData(bYear, bMonth), // revenue category could silently show up in an "expenses" trend.
getCategoryOverTime(dateFrom, dateTo, undefined, accountIds, "expense"),
getCartesSnapshot(refYear, refMonth, "month", accountIds),
]); ]);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } }); dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } });
} catch (e) { } catch (e) {
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ dispatch({
@ -124,8 +187,59 @@ export function useDashboard() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth); fetchData(
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]); state.period,
state.customDateFrom,
state.customDateTo,
state.referenceYear,
state.referenceMonth,
state.accountIds,
);
}, [
state.period,
state.customDateFrom,
state.customDateTo,
state.referenceYear,
state.referenceMonth,
state.accountIds,
fetchData,
]);
// One-time: import sources for the FilterPanel's account checkboxes (same
// query as Trends/Compare/Budget — see useTrends.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
// One-time: net-worth tile (Bilan). Independent of `period`/`referenceMonth`
// and NOT scoped by `accountIds` — a different "account" concept
// (balance_accounts vs import_sources) and a different metric (net worth vs
// transaction P&L). A failure here only hides the tile (best-effort); it
// never surfaces as a page-wide error for what is a secondary widget.
useEffect(() => {
(async () => {
try {
const [accounts, snapshots, totals] = await Promise.all([
listBalanceAccounts(),
listSnapshots(),
getSnapshotTotalsByDate(),
]);
dispatch({
type: "SET_NET_WORTH",
payload: deriveNetWorthTile(accounts.length, snapshots.length > 0, totals),
});
} catch {
dispatch({ type: "SET_NET_WORTH", payload: EMPTY_NET_WORTH });
}
})();
}, []);
const setPeriod = useCallback((period: DashboardPeriod) => { const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period }); dispatch({ type: "SET_PERIOD", payload: period });
@ -135,9 +249,13 @@ export function useDashboard() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } }); dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []); }, []);
const setBudgetMonth = useCallback((year: number, month: number) => { const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } }); dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []); }, []);
return { state, setPeriod, setCustomDates, setBudgetMonth }; const setAccountIds = useCallback((accountIds: number[]) => {
dispatch({ type: "SET_ACCOUNT_IDS", payload: accountIds });
}, []);
return { state, setPeriod, setCustomDates, setReferencePeriod, setAccountIds };
} }

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { resolveReportsPeriod } from "./useReportsPeriod"; import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } from "./useReportsPeriod";
describe("resolveReportsPeriod", () => { describe("resolveReportsPeriod", () => {
const fixedToday = new Date("2026-04-14T12:00:00Z"); const fixedToday = new Date("2026-04-14T12:00:00Z");
@ -51,3 +51,76 @@ describe("resolveReportsPeriod", () => {
expect(result.to).toBe("2026-12-31"); expect(result.to).toBe("2026-12-31");
}); });
}); });
describe("parseAccountIds", () => {
it("defaults to an empty array (no filter) when the sources param is absent", () => {
expect(parseAccountIds(null)).toEqual([]);
});
it("defaults to an empty array for an empty string", () => {
expect(parseAccountIds("")).toEqual([]);
});
it("parses a comma-separated list of ids", () => {
expect(parseAccountIds("3,7,12")).toEqual([3, 7, 12]);
});
it("trims whitespace around tokens", () => {
expect(parseAccountIds(" 3 , 7 ,12")).toEqual([3, 7, 12]);
});
it("parses a single id", () => {
expect(parseAccountIds("42")).toEqual([42]);
});
it("drops non-numeric tokens but keeps the valid ones (rejects non-numeric)", () => {
expect(parseAccountIds("3,abc,7")).toEqual([3, 7]);
});
it("drops decimal tokens (not a finite integer)", () => {
expect(parseAccountIds("3,4.5,7")).toEqual([3, 7]);
});
it("drops empty tokens produced by stray/trailing commas", () => {
expect(parseAccountIds("3,,7,")).toEqual([3, 7]);
});
it("drops exponential/hex-looking tokens that Number() would otherwise coerce", () => {
expect(parseAccountIds("3,1e3,0x10,7")).toEqual([3, 7]);
});
it("drops unsafe-integer tokens (too large to be a reliable id)", () => {
expect(parseAccountIds("3,99999999999999999999,7")).toEqual([3, 7]);
});
it("returns an empty array when every token is invalid", () => {
expect(parseAccountIds("abc,def")).toEqual([]);
});
});
describe("serializeAccountIds", () => {
it("joins ids into a comma-separated string", () => {
expect(serializeAccountIds([3, 7, 12])).toBe("3,7,12");
});
it("serializes a single id", () => {
expect(serializeAccountIds([42])).toBe("42");
});
it("returns null for an empty array so the param is removed (no filter)", () => {
expect(serializeAccountIds([])).toBeNull();
});
it("drops invalid entries (e.g. NaN) before serializing", () => {
expect(serializeAccountIds([3, NaN, 7])).toBe("3,7");
});
it("returns null when every entry is invalid", () => {
expect(serializeAccountIds([NaN, Infinity])).toBeNull();
});
it("round-trips through parseAccountIds", () => {
const ids = [1, 2, 3];
expect(parseAccountIds(serializeAccountIds(ids))).toEqual(ids);
});
});

View file

@ -26,6 +26,43 @@ function currentYearRange(today: Date = new Date()): { from: string; to: string
return { from: `${year}-01-01`, to: `${year}-12-31` }; return { from: `${year}-01-01`, to: `${year}-12-31` };
} }
// Matches a bare (optionally negative) integer token, e.g. "3" or "-3", but
// not "3.5", "abc", "" or "1e3" — deliberately stricter than `Number(...)` so
// that hex/exponential-notation strings from a hand-edited URL don't sneak
// through as valid ids.
const INTEGER_TOKEN = /^-?\d+$/;
/**
* Pure parser for the `sources` query param (comma-separated account/import
* source ids), exposed for the same testability reason as
* `resolveReportsPeriod`. Invalid tokens (non-numeric, decimal, empty from a
* stray comma, or too large to be a safe integer) are dropped individually
* rather than invalidating the whole list a single corrupted token in a
* bookmarked/hand-edited URL should not silently discard the rest of an
* otherwise valid selection. Missing param or no valid token `[]`, which
* means "no filter" (all accounts).
*/
export function parseAccountIds(raw: string | null): number[] {
if (!raw) return [];
return raw
.split(",")
.map((token) => token.trim())
.filter((token) => INTEGER_TOKEN.test(token))
.map(Number)
.filter((n) => Number.isSafeInteger(n));
}
/**
* Inverse of `parseAccountIds`: serializes account ids back to the `sources`
* query param value, or `null` when the param should be removed from the URL
* entirely (empty/all-invalid selection = no filter mirrors how `from`/`to`
* are omitted for a non-custom period).
*/
export function serializeAccountIds(accountIds: number[]): string | null {
const valid = accountIds.filter((n) => Number.isSafeInteger(n));
return valid.length > 0 ? valid.join(",") : null;
}
/** /**
* Pure resolver used by the hook and unit tests. Exposed to keep the core * Pure resolver used by the hook and unit tests. Exposed to keep the core
* logic hookless and testable without rendering a router. * logic hookless and testable without rendering a router.
@ -57,15 +94,19 @@ export interface UseReportsPeriodResult {
from: string; from: string;
to: string; to: string;
period: DashboardPeriod; period: DashboardPeriod;
accountIds: number[];
setPeriod: (period: DashboardPeriod) => void; setPeriod: (period: DashboardPeriod) => void;
setCustomDates: (from: string, to: string) => void; setCustomDates: (from: string, to: string) => void;
setAccountIds: (accountIds: number[]) => void;
} }
/** /**
* Reads/writes the active reporting period via the URL query string so it is * Reads/writes the active reporting period and, additively, the account
* bookmarkable and shared across the four report sub-routes. * (import source) filter via the URL query string so both are bookmarkable
* and shared across the report sub-routes.
* *
* Defaults to the current civil year (Jan 1 Dec 31). * Defaults to the current civil year (Jan 1 Dec 31) with no account filter
* (`accountIds: []`, meaning all accounts).
*/ */
export function useReportsPeriod(): UseReportsPeriodResult { export function useReportsPeriod(): UseReportsPeriodResult {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -73,12 +114,15 @@ export function useReportsPeriod(): UseReportsPeriodResult {
const rawPeriod = searchParams.get("period"); const rawPeriod = searchParams.get("period");
const rawFrom = searchParams.get("from"); const rawFrom = searchParams.get("from");
const rawTo = searchParams.get("to"); const rawTo = searchParams.get("to");
const rawSources = searchParams.get("sources");
const { from, to, period } = useMemo( const { from, to, period } = useMemo(
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod), () => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
[rawPeriod, rawFrom, rawTo], [rawPeriod, rawFrom, rawTo],
); );
const accountIds = useMemo(() => parseAccountIds(rawSources), [rawSources]);
const setPeriod = useCallback( const setPeriod = useCallback(
(next: DashboardPeriod) => { (next: DashboardPeriod) => {
setSearchParams( setSearchParams(
@ -115,5 +159,24 @@ export function useReportsPeriod(): UseReportsPeriodResult {
[setSearchParams], [setSearchParams],
); );
return { from, to, period, setPeriod, setCustomDates }; const setAccountIds = useCallback(
(nextAccountIds: number[]) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
const serialized = serializeAccountIds(nextAccountIds);
if (serialized === null) {
params.delete("sources");
} else {
params.set("sources", serialized);
}
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
return { from, to, period, accountIds, setPeriod, setCustomDates, setAccountIds };
} }

View file

@ -1,6 +1,7 @@
import { useReducer, useEffect, useRef, useCallback } from "react"; import { useReducer, useEffect, useRef, useCallback } from "react";
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types"; import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService"; import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
export type TrendsSubView = "global" | "byCategory"; export type TrendsSubView = "global" | "byCategory";
@ -9,6 +10,7 @@ interface State {
subView: TrendsSubView; subView: TrendsSubView;
monthlyTrends: MonthlyTrendItem[]; monthlyTrends: MonthlyTrendItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -18,12 +20,14 @@ type Action =
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] } | { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData } | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
const initialState: State = { const initialState: State = {
subView: "global", subView: "byCategory",
monthlyTrends: [], monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -38,6 +42,8 @@ function reducer(state: State, action: Action): State {
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null }; return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
case "SET_CATEGORY_OVER_TIME": case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null }; return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -46,20 +52,21 @@ function reducer(state: State, action: Action): State {
} }
export function useTrends() { export function useTrends() {
const { from, to } = useReportsPeriod(); const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => { const fetch = useCallback(
async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => {
const id = ++fetchIdRef.current; const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
if (subView === "global") { if (subView === "global") {
const data = await getMonthlyTrends(dateFrom, dateTo); const data = await getMonthlyTrends(dateFrom, dateTo, ids);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data }); dispatch({ type: "SET_TRENDS", payload: data });
} else { } else {
const data = await getCategoryOverTime(dateFrom, dateTo); const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
} }
@ -67,11 +74,26 @@ export function useTrends() {
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, []); },
[]
);
useEffect(() => { useEffect(() => {
fetch(state.subView, from, to); fetch(state.subView, from, to, accountIds);
}, [fetch, state.subView, from, to]); }, [fetch, state.subView, from, to, accountIds]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Transactions single-source filter — see useTransactions.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
const setSubView = useCallback((sv: TrendsSubView) => { const setSubView = useCallback((sv: TrendsSubView) => {
dispatch({ type: "SET_SUBVIEW", payload: sv }); dispatch({ type: "SET_SUBVIEW", payload: sv });

View file

@ -20,7 +20,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"balance": "Balance", "kpiSectionTitle": "Key indicators",
"income": "Income", "income": "Income",
"expenses": "Expenses", "expenses": "Expenses",
"net": "Net", "net": "Net",
@ -50,10 +50,11 @@
"help": { "help": {
"title": "How to use the Dashboard", "title": "How to use the Dashboard",
"tips": [ "tips": [
"Use the period selector (top right) to view different time ranges", "The filter bar at the top picks the date range and which import sources to include",
"Summary cards show your balance, income, and expenses for the selected period", "The key indicators (income, expenses, net, savings rate) cover the chosen reference month, with their change vs the previous month and vs last year",
"The pie chart breaks down your expenses by category", "The \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source",
"Recent transactions are listed at the bottom" "Rising/falling categories and budget adherence summarize the reference month's changes",
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
] ]
} }
}, },
@ -379,6 +380,10 @@
"budgetVsActual": "Budget vs Actual", "budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top", "subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom", "subtotalsOnBottom": "Subtotals on bottom",
"collapse": {
"expandAll": "Expand all",
"collapseAll": "Collapse all"
},
"detail": { "detail": {
"showAmounts": "Show amounts", "showAmounts": "Show amounts",
"hideAmounts": "Hide amounts" "hideAmounts": "Hide amounts"
@ -388,15 +393,18 @@
"search": "Search...", "search": "Search...",
"all": "All", "all": "All",
"none": "None", "none": "None",
"allTypes": "All types" "allTypes": "All types",
"accounts": {
"label": "Import sources",
"hint": "No selection = all sources"
}
}, },
"bva": { "bva": {
"monthly": "Monthly", "monthly": "Monthly",
"ytd": "Year-to-Date", "ytd": "Year-to-Date",
"dollarVar": "$ Var", "dollarVar": "$ Var",
"pctVar": "% Var", "pctVar": "% Var",
"noData": "No budget or transaction data for this period.", "noData": "No budget or transaction data for this period."
"titlePrefix": "Budget vs Actual for"
}, },
"export": "Export", "export": "Export",
"month": "Month", "month": "Month",

View file

@ -20,7 +20,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
"balance": "Solde", "kpiSectionTitle": "Indicateurs clés",
"income": "Revenus", "income": "Revenus",
"expenses": "Dépenses", "expenses": "Dépenses",
"net": "Net", "net": "Net",
@ -50,10 +50,11 @@
"help": { "help": {
"title": "Comment utiliser le tableau de bord", "title": "Comment utiliser le tableau de bord",
"tips": [ "tips": [
"Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates", "Le filtre en haut choisit la plage de dates et les sources d'import à inclure",
"Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée", "Les indicateurs clés (revenus, dépenses, net, taux d'épargne) portent sur le mois de référence choisi, avec leur variation vs le mois précédent et vs l'an dernier",
"Le graphique circulaire détaille vos dépenses par catégorie", "La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import",
"Les transactions récentes sont listées en bas de page" "Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
] ]
} }
}, },
@ -379,6 +380,10 @@
"budgetVsActual": "Budget vs Réel", "budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut", "subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas", "subtotalsOnBottom": "Sous-totaux en bas",
"collapse": {
"expandAll": "Tout déplier",
"collapseAll": "Tout replier"
},
"detail": { "detail": {
"showAmounts": "Afficher les montants", "showAmounts": "Afficher les montants",
"hideAmounts": "Masquer les montants" "hideAmounts": "Masquer les montants"
@ -388,15 +393,18 @@
"search": "Rechercher...", "search": "Rechercher...",
"all": "Toutes", "all": "Toutes",
"none": "Aucune", "none": "Aucune",
"allTypes": "Tous les types" "allTypes": "Tous les types",
"accounts": {
"label": "Sources d'import",
"hint": "Aucune sélection = toutes les sources"
}
}, },
"bva": { "bva": {
"monthly": "Mensuel", "monthly": "Mensuel",
"ytd": "Cumul annuel", "ytd": "Cumul annuel",
"dollarVar": "$ Écart", "dollarVar": "$ Écart",
"pctVar": "% Écart", "pctVar": "% Écart",
"noData": "Aucune donnée de budget ou de transaction pour cette période.", "noData": "Aucune donnée de budget ou de transaction pour cette période."
"titlePrefix": "Budget vs Réel pour le mois de"
}, },
"export": "Exporter", "export": "Exporter",
"month": "Mois", "month": "Mois",

View file

@ -1,12 +1,15 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import FilterPanel from "../components/reports/FilterPanel";
import { useBudget } from "../hooks/useBudget"; import { useBudget } from "../hooks/useBudget";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import YearNavigator from "../components/budget/YearNavigator"; import YearNavigator from "../components/budget/YearNavigator";
import BudgetTable from "../components/budget/BudgetTable"; import BudgetTable from "../components/budget/BudgetTable";
import TemplateActions from "../components/budget/TemplateActions"; import TemplateActions from "../components/budget/TemplateActions";
export default function BudgetPage() { export default function BudgetPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { accountIds, setAccountIds } = useReportsPeriod();
const { const {
state, state,
navigateYear, navigateYear,
@ -18,16 +21,15 @@ export default function BudgetPage() {
deleteTemplate, deleteTemplate,
} = useBudget(); } = useBudget();
const { year, rows, templates, isLoading, isSaving, error } = state; const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
return ( return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}> <div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("budget.title")}</h1> <h1 className="text-2xl font-bold">{t("budget.title")}</h1>
<PageHelp helpKey="budget" /> <PageHelp helpKey="budget" />
</div> </div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<TemplateActions <TemplateActions
templates={templates} templates={templates}
onApply={applyTemplate} onApply={applyTemplate}
@ -36,10 +38,15 @@ export default function BudgetPage() {
onDelete={deleteTemplate} onDelete={deleteTemplate}
disabled={isSaving} disabled={isSaving}
/> />
<YearNavigator year={year} onNavigate={navigateYear} />
</div>
</div> </div>
<FilterPanel
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
{error && ( {error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20"> <div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{error} {error}

View file

@ -1,23 +1,40 @@
// DashboardPage — home page at `/`.
//
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
// row, top movers and budget adherence are the exact Cartes presentational
// components, sourced from the same `getCartesSnapshot` reference-month
// snapshot. Two Dashboard-specific widgets remain: the top-expense-categories
// ranked bar chart (replacing the former expense-only pie) and the net-worth
// tile (from the Bilan — a different metric from every transactional widget
// here, see NetWorthTile's own header comment).
import { useState, useCallback, useMemo } 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 { useDashboard } from "../hooks/useDashboard"; import { useDashboard } from "../hooks/useDashboard";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart"; import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner"; import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
import NetWorthTile from "../components/dashboard/NetWorthTile";
import FilterPanel from "../components/reports/FilterPanel";
import KpiCard from "../components/reports/cards/KpiCard";
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
import TopMoversList from "../components/reports/cards/TopMoversList";
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import TransactionDetailModal from "../components/shared/TransactionDetailModal"; import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem } from "../shared/types"; import type { CategoryBreakdownItem } from "../shared/types";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange"; import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
export default function DashboardPage() { export default function DashboardPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard(); const {
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state; state,
setPeriod,
setCustomDates,
setReferencePeriod,
setAccountIds,
} = useDashboard();
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null); const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -37,35 +54,6 @@ export default function DashboardPage() {
setDetailModal(item); setDetailModal(item);
}, []); }, []);
const balance = summary.totalAmount;
const balanceColor =
balance > 0
? "text-[var(--positive)]"
: balance < 0
? "text-[var(--negative)]"
: "text-[var(--primary)]";
const cards = [
{
labelKey: "dashboard.balance",
value: fmt.format(balance),
icon: Wallet,
color: balanceColor,
},
{
labelKey: "dashboard.income",
value: fmt.format(summary.incomeTotal),
icon: TrendingUp,
color: "text-[var(--positive)]",
},
{
labelKey: "dashboard.expenses",
value: fmt.format(Math.abs(summary.expenseTotal)),
icon: TrendingDown,
color: "text-[var(--negative)]",
},
];
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]); 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);
@ -73,11 +61,19 @@ export default function DashboardPage() {
return ( return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}> <div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<CategoriesV1DiscoveryBanner /> <CategoriesV1DiscoveryBanner />
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="relative flex items-center gap-3 mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1> <h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
<PageHelp helpKey="dashboard" /> <PageHelp helpKey="dashboard" />
</div> </div>
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}
</div>
)}
<FilterPanel
temporalControl={
<PeriodSelector <PeriodSelector
value={period} value={period}
onChange={setPeriod} onChange={setPeriod}
@ -85,46 +81,21 @@ export default function DashboardPage() {
customDateTo={state.customDateTo} customDateTo={state.customDateTo}
onCustomDateChange={setCustomDates} onCustomDateChange={setCustomDates}
/> />
</div> }
accountIds={state.accountIds}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> onAccountIdsChange={setAccountIds}
{cards.map((card) => ( accounts={state.accounts}
<div
key={card.labelKey}
className="bg-[var(--card)] rounded-xl p-5 border border-[var(--border)] shadow-sm"
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-[var(--muted-foreground)]">
{t(card.labelKey)}
</span>
<card.icon size={20} className={card.color} />
</div>
<p className="text-2xl font-semibold">{card.value}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
<div className="lg:col-span-1">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<CategoryPieChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/> />
</div>
<div className="lg:col-span-3"> <div className="flex items-center gap-2 mb-3 flex-wrap">
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap"> <h2 className="text-lg font-semibold">{t("dashboard.kpiSectionTitle")}</h2>
{t("reports.bva.titlePrefix")}
<select <select
value={`${state.budgetYear}-${state.budgetMonth}`} value={`${state.referenceYear}-${state.referenceMonth}`}
onChange={(e) => { onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number); const [y, m] = e.target.value.split("-").map(Number);
setBudgetMonth(y, m); setReferencePeriod(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" className="text-sm font-medium 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) => ( {monthOptions.map((opt) => (
<option key={opt.key} value={opt.value}> <option key={opt.key} value={opt.value}>
@ -132,9 +103,75 @@ export default function DashboardPage() {
</option> </option>
))} ))}
</select> </select>
</h2>
<BudgetVsActualTable data={budgetVsActual} />
</div> </div>
<div className="flex flex-wrap gap-3 mb-6">
<NetWorthTile data={state.netWorth} />
{cartesSnapshot && (
<>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="income"
title={t("reports.cartes.income")}
kpi={cartesSnapshot.kpis.income}
format="currency"
deltaIsBadWhenUp={false}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="expenses"
title={t("reports.cartes.expenses")}
kpi={cartesSnapshot.kpis.expenses}
format="currency"
deltaIsBadWhenUp={true}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="net"
title={t("reports.cartes.net")}
kpi={cartesSnapshot.kpis.net}
format="currency"
deltaIsBadWhenUp={false}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="savingsRate"
title={t("reports.cartes.savingsRate")}
kpi={cartesSnapshot.kpis.savingsRate}
format="percent"
deltaIsBadWhenUp={false}
/>
</div>
</>
)}
</div>
{cartesSnapshot && (
<>
<div className="mb-6">
<IncomeExpenseOverlayChart flow={cartesSnapshot.flow12Months} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
<TopMoversList movers={cartesSnapshot.topMoversUp} direction="up" />
<TopMoversList movers={cartesSnapshot.topMoversDown} direction="down" />
<BudgetAdherenceCard adherence={cartesSnapshot.budgetAdherence} />
</div>
</>
)}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<CategoryBarChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
</div> </div>
<div className="mb-6"> <div className="mb-6">

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import CompareModeTabs from "../components/reports/CompareModeTabs"; import CompareModeTabs from "../components/reports/CompareModeTabs";
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle"; import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
@ -25,7 +26,8 @@ function formatMonthLabel(year: number, month: number, language: string): string
export default function ReportsComparePage() { export default function ReportsComparePage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } =
useReportsPeriod();
const { const {
mode, mode,
subMode, subMode,
@ -35,6 +37,7 @@ export default function ReportsComparePage() {
year, year,
month, month,
rows, rows,
accounts,
isLoading, isLoading,
error, error,
} = useCompare(); } = useCompare();
@ -90,21 +93,25 @@ export default function ReportsComparePage() {
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap"> <FilterPanel
<div className="flex items-center gap-3 flex-wrap"> temporalControl={
<CompareReferenceMonthPicker <CompareReferenceMonthPicker
year={year} year={year}
month={month} month={month}
onChange={setReferencePeriod} onChange={setReferencePeriod}
/> />
}
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
{showActualControls && ( {showActualControls && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<CompareSubModeToggle value={subMode} onChange={setSubMode} /> <CompareSubModeToggle value={subMode} onChange={setSubMode} />
)}
</div>
{showActualControls && (
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} /> <ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
)}
</div> </div>
)}
{error && ( {error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6"> <div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
@ -113,7 +120,7 @@ export default function ReportsComparePage() {
)} )}
{mode === "budget" ? ( {mode === "budget" ? (
<CompareBudgetView year={year} month={month} /> <CompareBudgetView year={year} month={month} accountIds={accountIds} />
) : viewMode === "chart" ? ( ) : viewMode === "chart" ? (
<ComparePeriodChart <ComparePeriodChart
rows={rows} rows={rows}

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
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 CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
@ -21,9 +22,9 @@ const STORAGE_KEY = "reports-viewmode-trends";
export default function ReportsTrendsPage() { export default function ReportsTrendsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends(); const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY)); const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType()); const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
@ -55,7 +56,8 @@ export default function ReportsTrendsPage() {
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1> <h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap"> <FilterPanel
temporalControl={
<PeriodSelector <PeriodSelector
value={period} value={period}
onChange={setPeriod} onChange={setPeriod}
@ -63,7 +65,13 @@ export default function ReportsTrendsPage() {
customDateTo={to} customDateTo={to}
onCustomDateChange={setCustomDates} onCustomDateChange={setCustomDates}
/> />
<div className="flex gap-2 items-center flex-wrap"> }
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
<div className="flex gap-2 items-center justify-end flex-wrap mb-6">
<div className="inline-flex gap-1"> <div className="inline-flex gap-1">
<button <button
type="button" type="button"
@ -99,7 +107,6 @@ export default function ReportsTrendsPage() {
)} )}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} /> <ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div> </div>
</div>
{error && ( {error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6"> <div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
@ -123,7 +130,7 @@ export default function ReportsTrendsPage() {
chartType={chartType} chartType={chartType}
/> />
) : ( ) : (
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} /> <CategoryOverTimeTable data={categoryOverTime} />
)} )}
</div> </div>
); );

View file

@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
vi.mock("./db", () => {
const getDb = vi.fn();
return { getDb };
});
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
// getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories,
// getBudgetEntriesForYear, and getActualsByCategoryRange for the month + YTD
// windows). Only the two `getActualsByCategoryRange` calls query `transactions`
// and are eligible for the accountIds filter — budgets are not tied to an
// account, so the categories/entries queries must never carry the clause.
describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
it("without accountIds, the actuals queries carry no source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
expect(actualsCalls.length).toBe(2); // month + YTD
for (const [sql, params] of actualsCalls) {
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toHaveLength(2); // dateFrom, dateTo only
}
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, []);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
for (const [sql, params] of actualsCalls) {
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toHaveLength(2);
}
});
it("applies a parameterized accountIds IN clause to both the month and YTD actuals queries", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, [4, 9]);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
expect(actualsCalls.length).toBe(2);
for (const [sql, params] of actualsCalls) {
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(sql as string).not.toContain("4,9");
expect(params as unknown[]).toEqual(expect.arrayContaining([4, 9]));
}
});
it("never adds the filter to the categories/budget-entries queries (a budget is not tied to an account)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, [4]);
const categoriesCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("FROM categories"),
)!;
const budgetEntriesCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("FROM budget_entries"),
)!;
expect(categoriesCall[0]).not.toContain("source_id");
expect(budgetEntriesCall[0]).not.toContain("source_id");
});
});
// getActualTotalsForYear is the previous-year actuals reference column
// consumed by useBudget (Issue #276) — a thin wrapper over the same
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through.
describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => {
it("without accountIds, the query carries no source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025);
expect(mockSelect).toHaveBeenCalledTimes(1);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025, []);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025, [4, 9]);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
Category, Category,
BudgetEntry, BudgetEntry,
@ -181,34 +182,49 @@ export async function deleteTemplate(templateId: number): Promise<void> {
// --- Actuals helpers --- // --- Actuals helpers ---
export async function getActualTotalsForYear( export async function getActualTotalsForYear(
year: number year: number,
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> { ): Promise<Array<{ category_id: number | null; actual: number }>> {
const dateFrom = `${year}-01-01`; const dateFrom = `${year}-01-01`;
const dateTo = `${year}-12-31`; const dateTo = `${year}-12-31`;
return getActualsByCategoryRange(dateFrom, dateTo); return getActualsByCategoryRange(dateFrom, dateTo, accountIds);
} }
// --- Budget vs Actual --- // --- Budget vs Actual ---
async function getActualsByCategoryRange( async function getActualsByCategoryRange(
dateFrom: string, dateFrom: string,
dateTo: string dateTo: string,
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> { ): Promise<Array<{ category_id: number | null; actual: number }>> {
const db = await getDb(); const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, params.length + 1);
const accountFilter = accountPlaceholders ? ` AND source_id IN (${accountPlaceholders})` : "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<Array<{ category_id: number | null; actual: number }>>( return db.select<Array<{ category_id: number | null; actual: number }>>(
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual `SELECT category_id, COALESCE(SUM(amount), 0) AS actual
FROM transactions FROM transactions
WHERE date BETWEEN $1 AND $2 WHERE date BETWEEN $1 AND $2${accountFilter}
GROUP BY category_id`, GROUP BY category_id`,
[dateFrom, dateTo] params
); );
} }
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; // Income-statement reading order: revenue first, then expenses, then
// transfers (Issue #277 — matches COMPARE_TYPE_ORDER / OVER_TIME_TYPE_ORDER).
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/**
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
* (Issue #273) optionally scopes both the month and YTD actuals to a subset of
* import sources (`transactions.source_id`) budgeted amounts are unaffected
* by the filter (a budget is not tied to any account).
*/
export async function getBudgetVsActualData( export async function getBudgetVsActualData(
year: number, year: number,
month: number month: number,
accountIds?: number[]
): Promise<BudgetVsActualRow[]> { ): Promise<BudgetVsActualRow[]> {
// Date ranges // Date ranges
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month); const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
@ -219,8 +235,8 @@ export async function getBudgetVsActualData(
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([ const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualsByCategoryRange(monthFrom, monthTo), getActualsByCategoryRange(monthFrom, monthTo, accountIds),
getActualsByCategoryRange(ytdFrom, ytdTo), getActualsByCategoryRange(ytdFrom, ytdTo, accountIds),
]); ]);
// Build maps // Build maps

View file

@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getExpensesByCategory, deriveNetWorthTile } from "./dashboardService";
import type { SnapshotTotalPoint } from "./balance.service";
vi.mock("./db", () => {
const getDb = vi.fn();
return { getDb };
});
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
describe("getExpensesByCategory — accountIds filter (Issue #273)", () => {
it("without accountIds, the query carries no source_id clause (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31");
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31", []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31", [6, 11]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("t.source_id IN ($3, $4)");
expect(sql).not.toContain("6,11");
expect(params).toEqual(["2025-01-01", "2025-12-31", 6, 11]);
});
it("applies the accountIds filter alone (no dates) alongside the fixed expense-type clause", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory(undefined, undefined, [2]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("t.source_id IN ($1)");
expect(sql).toContain("COALESCE(c.type, 'expense') = 'expense'");
expect(params).toEqual([2]);
});
});
describe("deriveNetWorthTile (Issue #279)", () => {
const totals: SnapshotTotalPoint[] = [
{ snapshot_date: "2026-01-31", total: 1000 },
{ snapshot_date: "2026-02-28", total: 1234.56 },
];
it("is hidden when there is no balance account at all", () => {
expect(deriveNetWorthTile(0, false, totals)).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is hidden when accounts exist but no snapshot has ever been recorded (never a misleading $0)", () => {
expect(deriveNetWorthTile(3, false, [])).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is hidden when accounts exist but no snapshot has ever been recorded, even if totals is non-empty (defensive)", () => {
expect(deriveNetWorthTile(3, false, totals)).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
it("is visible with the latest (last) point once accounts and a snapshot both exist", () => {
expect(deriveNetWorthTile(3, true, totals)).toEqual({
visible: true,
total: 1234.56,
asOfDate: "2026-02-28",
});
});
it("stays hidden if the two signals disagree with an empty totals series (defensive guard)", () => {
expect(deriveNetWorthTile(3, true, [])).toEqual({
visible: false,
total: null,
asOfDate: null,
});
});
});

View file

@ -1,4 +1,7 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { inPlaceholders } from "../utils/sqlFilters";
import { deriveLandingState } from "../components/balance/balanceLanding";
import type { SnapshotTotalPoint } from "./balance.service";
import type { import type {
DashboardSummary, DashboardSummary,
CategoryBreakdownItem, CategoryBreakdownItem,
@ -54,7 +57,7 @@ export async function getDashboardSummary(
export async function getExpensesByCategory( export async function getExpensesByCategory(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
sourceId?: number, accountIds?: number[],
): Promise<CategoryBreakdownItem[]> { ): Promise<CategoryBreakdownItem[]> {
const db = await getDb(); const db = await getDb();
@ -72,10 +75,11 @@ export async function getExpensesByCategory(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) { const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
whereClauses.push(`t.source_id = $${paramIndex}`); if (accountPlaceholders) {
params.push(sourceId); whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
paramIndex++; params.push(...accountIds!);
paramIndex += accountIds!.length;
} }
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`; const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
@ -162,3 +166,47 @@ export async function getRecentTransactions(
[limit] [limit]
); );
} }
// --- Net-worth tile (Issue #279) ---
//
// The Dashboard's net-worth tile surfaces the Bilan's latest total
// (`getSnapshotTotalsByDate`, balance.service.ts:1767) — a DIFFERENT metric
// from every other Dashboard widget: it is the latest snapshot of account
// balances, not a transaction P&L. It must never be confused with (or scoped
// by) the transactional account/import-source filter, and must never render
// a misleading "$0" before any snapshot has ever been recorded.
export interface NetWorthTileData {
visible: boolean;
total: number | null;
asOfDate: string | null;
}
const EMPTY_NET_WORTH_TILE: NetWorthTileData = {
visible: false,
total: null,
asOfDate: null,
};
/**
* Shapes the net-worth tile from the same two signals BalancePage's landing
* state already uses (`deriveLandingState`) reused here rather than
* re-inferring "no data" from nulls, per the #279 review caveat. The tile
* stays hidden (never a misleading "$0") unless at least one balance account
* exists AND at least one snapshot has ever been recorded; `totals` (the
* `getSnapshotTotalsByDate` series, ascending by date) is otherwise expected
* to carry at least one point, but a defensive check guards a theoretical
* mismatch between the two independent queries.
*/
export function deriveNetWorthTile(
accountsCount: number,
hasAnySnapshot: boolean,
totals: SnapshotTotalPoint[]
): NetWorthTileData {
if (deriveLandingState(accountsCount, hasAnySnapshot) !== "populated") {
return EMPTY_NET_WORTH_TILE;
}
const latest = totals[totals.length - 1];
if (!latest) return EMPTY_NET_WORTH_TILE;
return { visible: true, total: latest.total, asOfDate: latest.snapshot_date };
}

View file

@ -469,4 +469,77 @@ describe("getCartesSnapshot", () => {
expect(worst.overrunAbs).toBe(150); expect(worst.overrunAbs).toBe(150);
expect(worst.overrunPct).toBeCloseTo(75, 5); expect(worst.overrunPct).toBeCloseTo(75, 5);
}); });
// --- accountIds filter (Issues #273 / #279) ---
//
// getCartesSnapshot must forward accountIds to EVERY sub-query so the whole
// dashboard honours an active account filter: the two sub-reports that
// support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData
// for budget adherence) AND the KPI/sparkline/seasonality series
// (fetchMonthlyFlows, fetchSeasonality). Omitting it anywhere would let the
// dashboard silently mix filtered and unfiltered figures on the same screen.
it("forwards accountIds to the compare (top movers) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
const momCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("ORDER BY ABS(month_current_total - month_previous_total) DESC"),
)!;
expect(momCall[0]).toContain("AND t.source_id IN ($9, $10)");
expect(momCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the budget-vs-actual (budget adherence) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions\n WHERE date BETWEEN"),
);
// Both the month and YTD actuals queries (getBudgetVsActualData) receive it.
expect(actualsCalls.length).toBeGreaterThanOrEqual(2);
for (const [sql, params] of actualsCalls) {
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(params as unknown[]).toEqual(expect.arrayContaining([3, 8]));
}
});
it("forwards accountIds to the monthly-flows series (KPIs, sparklines, overlay)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchMonthlyFlows is the only snapshot query selecting a "%Y-%m" month.
const flowCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%Y-%m', date) AS month"),
)!;
expect(flowCall[0]).toContain("AND source_id IN ($3, $4)");
expect(flowCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the seasonality series", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchSeasonality is the only snapshot query filtering on a "%m" month.
const seasonalityCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%m', date) = $1"),
)!;
expect(seasonalityCall[0]).toContain("AND source_id IN ($4, $5)");
expect(seasonalityCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3);
for (const [sql] of mockSelect.mock.calls) {
expect(sql as string).not.toContain("source_id");
}
});
}); });

View file

@ -1,13 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { import {
getMonthlyTrends,
getCategoryOverTime, getCategoryOverTime,
getHighlights, getHighlights,
getCompareMonthOverMonth, getCompareMonthOverMonth,
getCompareYearOverYear, getCompareYearOverYear,
getCategoryZoom, getCategoryZoom,
buildCompareTree, buildCompareTree,
buildOverTimeTree,
} from "./reportService"; } from "./reportService";
import type { CategoryDelta } from "../shared/types"; import type { CategoryDelta, OverTimeRow } from "../shared/types";
// Mock the db module // Mock the db module
vi.mock("./db", () => { vi.mock("./db", () => {
@ -28,6 +30,66 @@ beforeEach(() => {
mockSelect.mockReset(); mockSelect.mockReset();
}); });
describe("getMonthlyTrends", () => {
it("builds query without WHERE clause when no filters are provided (regression: byte-identical to pre-#273)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends();
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("WHERE");
expect(params).toEqual([]);
});
it("applies date range filters without an accountIds argument (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31");
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("date >= $1");
expect(sql).toContain("date <= $2");
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31", []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31", [3, 7]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("source_id IN ($3, $4)");
expect(sql).not.toContain("3,7");
expect(params).toEqual(["2025-01-01", "2025-12-31", 3, 7]);
});
it("applies the accountIds filter alone (no dates)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends(undefined, undefined, [5]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("WHERE source_id IN ($1)");
expect(params).toEqual([5]);
});
});
describe("getCategoryOverTime", () => { describe("getCategoryOverTime", () => {
it("builds query without WHERE clause when no filters are provided", async () => { it("builds query without WHERE clause when no filters are provided", async () => {
// First call: top categories, second call: monthly breakdown // First call: top categories, second call: monthly breakdown
@ -86,32 +148,60 @@ describe("getCategoryOverTime", () => {
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]); expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
}); });
it("applies sourceId filter", async () => { it("applies accountIds filter (single id)", async () => {
mockSelect mockSelect
.mockResolvedValueOnce([]) // topCategories .mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows .mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime(undefined, undefined, 50, 3); await getCategoryOverTime(undefined, undefined, 50, [3]);
const topCatSQL = mockSelect.mock.calls[0][0] as string; const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[]; const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id"); expect(topCatSQL).toContain("t.source_id IN ($1)");
expect(topCatParams[0]).toBe(3); expect(topCatParams[0]).toBe(3);
}); });
it("combines typeFilter, date range, and sourceId", async () => { it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => {
mockSelect mockSelect
.mockResolvedValueOnce([]) // topCategories .mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows .mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense"); await getCategoryOverTime(undefined, undefined, 50, [3, 7, 12]);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id IN ($1, $2, $3)");
expect(topCatSQL).not.toContain("3,7,12");
// topN ($4) follows the 3 account placeholders.
expect(topCatParams).toEqual([3, 7, 12, 50]);
});
it("an empty accountIds array behaves exactly like no filter (no clause, no orphan param)", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-12-31", 50, []);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).not.toContain("source_id");
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
});
it("combines typeFilter, date range, and accountIds", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, [2], "expense");
const topCatSQL = mockSelect.mock.calls[0][0] as string; const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[]; const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1"); expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
expect(topCatSQL).toContain("t.date >= $2"); expect(topCatSQL).toContain("t.date >= $2");
expect(topCatSQL).toContain("t.date <= $3"); expect(topCatSQL).toContain("t.date <= $3");
expect(topCatSQL).toContain("t.source_id = $4"); expect(topCatSQL).toContain("t.source_id IN ($4)");
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]); expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
}); });
@ -163,23 +253,66 @@ describe("getCategoryOverTime", () => {
expect(result.types).toEqual({ Food: "expense" }); expect(result.types).toEqual({ Food: "expense" });
}); });
it("groups non-top-N categories into Other (Other left out of the types map)", async () => { it("keeps the pivot top-N + Other but the id-keyed tree carries every category (Issue #264)", async () => {
mockSelect mockSelect
// 1. top categories — top-N = 1, so only Food.
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 }, { category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
]) ])
// 2. monthly breakdown — every category (no LIMIT here).
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }, { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 }, { month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
{ month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 }, { month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
])
// 3. category metadata for the tree.
.mockResolvedValueOnce([
{ id: 1, name: "Food", color: "#ff0000", type: "expense", parent_id: null },
{ id: 2, name: "Transport", color: "#00ff00", type: "expense", parent_id: null },
{ id: 3, name: "Entertainment", color: "#0000ff", type: "expense", parent_id: null },
]); ]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense"); const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");
// Pivot path is UNCHANGED — top-N (Food) plus an "Other" bucket for the rest.
expect(result.categories).toEqual(["Food", "Other"]); expect(result.categories).toEqual(["Food", "Other"]);
expect(result.colors["Other"]).toBe("#9ca3af"); expect(result.colors["Other"]).toBe("#9ca3af");
expect(result.types).toEqual({ Food: "expense" }); expect(result.types).toEqual({ Food: "expense" });
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 }); expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
// Sidecar tree — EVERY category (no top-N, no "Other"), keyed by id.
const treeNames = result.tree.map((r) => r.categoryName);
expect(treeNames).toEqual(expect.arrayContaining(["Food", "Transport", "Entertainment"]));
expect(treeNames).not.toContain("Other");
const food = result.tree.find((r) => r.categoryId === 1)!;
expect(food.monthly).toEqual([300]);
expect(food.total).toBe(300);
expect(food.category_type).toBe("expense");
expect(food.is_parent).toBe(false);
});
it("separates homonym categories of different types in the tree (Issue #264)", async () => {
mockSelect
.mockResolvedValueOnce([]) // top categories (irrelevant to the tree)
.mockResolvedValueOnce([
// Two DIFFERENT categories both named "Divers".
{ month: "2025-01", category_id: 1, category_name: "Divers", total: 1000 },
{ month: "2025-01", category_id: 2, category_name: "Divers", total: 300 },
])
.mockResolvedValueOnce([
{ id: 1, name: "Divers", color: null, type: "income", parent_id: null },
{ id: 2, name: "Divers", color: null, type: "expense", parent_id: null },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31");
// The pivot merges them (single "Divers" cell); the tree keeps them apart.
const incomeDivers = result.tree.find((r) => r.categoryId === 1)!;
const expenseDivers = result.tree.find((r) => r.categoryId === 2)!;
expect(incomeDivers.category_type).toBe("income");
expect(incomeDivers.total).toBe(1000);
expect(expenseDivers.category_type).toBe("expense");
expect(expenseDivers.total).toBe(300);
}); });
}); });
@ -448,6 +581,46 @@ describe("getCompareMonthOverMonth", () => {
expect(result[0].deltaAbs).toBe(50); expect(result[0].deltaAbs).toBe(50);
expect(result[0].cumulativeDeltaAbs).toBe(80); expect(result[0].cumulativeDeltaAbs).toBe(80);
}); });
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4, []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4, [3, 7]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("AND t.source_id IN ($9, $10)");
expect(sql).not.toContain("3,7");
expect(params).toEqual([
"2026-04-01", "2026-04-30",
"2026-03-01", "2026-03-31",
"2026-01-01", "2026-04-30",
"2026-01-01", "2026-03-31",
3, 7,
]);
});
}); });
describe("getCompareYearOverYear", () => { describe("getCompareYearOverYear", () => {
@ -505,6 +678,34 @@ describe("getCompareYearOverYear", () => {
expect(result[0].deltaPct).toBeCloseTo(50, 4); expect(result[0].deltaPct).toBeCloseTo(50, 4);
expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4); expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4);
}); });
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4, [9]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("AND t.source_id IN ($9)");
expect(params).toEqual([
"2026-04-01", "2026-04-30",
"2025-04-01", "2025-04-30",
"2026-01-01", "2026-04-30",
"2025-01-01", "2025-04-30",
9,
]);
});
}); });
describe("real-vs-real compare — transfer netting (Issue #243)", () => { describe("real-vs-real compare — transfer netting (Issue #243)", () => {
@ -800,6 +1001,114 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
}); });
}); });
describe("buildOverTimeTree — hierarchical trends (Issue #264)", () => {
/** Flat id-keyed leaf, mirroring what getCategoryOverTime feeds the builder. */
function otLeaf(categoryId: number | null, categoryName: string, monthly: number[]): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
}
const cats = [
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
{ id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
] as Parameters<typeof buildOverTimeTree>[1];
it("nests leaves under a parent subtotal equal to the per-month sum of its children", () => {
const rows = buildOverTimeTree(
[otLeaf(22, "Épicerie", [500, 400]), otLeaf(24, "Restaurant", [120, 200])],
cats,
2,
);
// Parent subtotal on top, then children ordered by |total| desc.
expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);
const parent = rows[0];
expect(parent.is_parent).toBe(true);
expect(parent.categoryId).toBe(2);
expect(parent.depth).toBe(0);
expect(parent.category_type).toBe("expense");
// Subtotal series is index-aligned and equals the per-month child sums.
expect(parent.monthly).toEqual([620, 600]);
expect(parent.total).toBe(1220);
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
expect(epicerie.is_parent).toBe(false);
expect(epicerie.depth).toBe(1);
expect(epicerie.parent_id).toBe(2);
expect(epicerie.monthly).toEqual([500, 400]);
});
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
const rows = buildOverTimeTree(
[otLeaf(80, "Paie", [4200]), otLeaf(22, "Épicerie", [500]), otLeaf(50, "REER", [0])],
cats,
1,
);
// Each leaf sits under a parent, so every section yields [subtotal, leaf].
expect(rows.map((r) => r.category_type)).toEqual([
"income",
"income",
"expense",
"expense",
"transfer",
"transfer",
]);
});
it("preserves grand-total invariance vs the flat leaves", () => {
const flat = [
otLeaf(22, "Épicerie", [500, 400]),
otLeaf(24, "Restaurant", [120, 200]),
otLeaf(50, "REER", [0, 0]),
];
const rows = buildOverTimeTree(flat, cats, 2);
const sumLeaves = (rs: OverTimeRow[]) =>
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.total, 0);
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
});
it("surfaces an uncategorized (null id) leaf as a top-level row", () => {
const rows = buildOverTimeTree([otLeaf(null, "Uncategorized", [90])], cats, 1);
expect(rows).toHaveLength(1);
expect(rows[0].categoryName).toBe("Uncategorized");
expect(rows[0].is_parent).toBe(false);
expect(rows[0].depth).toBe(0);
expect(rows[0].monthly).toEqual([90]);
});
it("terminates on a cyclic parent_id chain (A → B → A) without overflowing", () => {
const cyclic = [
{ id: 1, name: "A", color: null, type: "expense", parent_id: 2 },
{ id: 2, name: "B", color: null, type: "expense", parent_id: 1 },
] as Parameters<typeof buildOverTimeTree>[1];
// The relevant-ancestor walk and buildNode both cap at MAX_TREE_DEPTH, so a
// corrupted cycle returns (no root emerges) instead of recursing forever —
// .not.toThrow() catches a "Maximum call stack" RangeError if it overflowed.
let rows: OverTimeRow[] = [];
expect(() => {
rows = buildOverTimeTree([otLeaf(1, "A", [100])], cyclic, 1);
}).not.toThrow();
expect(Array.isArray(rows)).toBe(true);
for (const r of rows) expect(r.depth).toBeLessThanOrEqual(5);
});
});
describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => { describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
it("returns parent subtotal rows when category metadata is available", async () => { it("returns parent subtotal rows when category metadata is available", async () => {
mockSelect mockSelect

View file

@ -1,10 +1,12 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService"; import { getBudgetVsActualData } from "./budgetService";
import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
MonthlyTrendItem, MonthlyTrendItem,
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, CategoryOverTimeData,
CategoryOverTimeItem, CategoryOverTimeItem,
OverTimeRow,
HighlightsData, HighlightsData,
HighlightMover, HighlightMover,
CategoryDelta, CategoryDelta,
@ -28,7 +30,7 @@ import type {
export async function getMonthlyTrends( export async function getMonthlyTrends(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
sourceId?: number, accountIds?: number[],
): Promise<MonthlyTrendItem[]> { ): Promise<MonthlyTrendItem[]> {
const db = await getDb(); const db = await getDb();
@ -46,10 +48,11 @@ export async function getMonthlyTrends(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) { const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
whereClauses.push(`source_id = $${paramIndex}`); if (accountPlaceholders) {
params.push(sourceId); whereClauses.push(`source_id IN (${accountPlaceholders})`);
paramIndex++; params.push(...accountIds!);
paramIndex += accountIds!.length;
} }
const whereSQL = const whereSQL =
@ -72,7 +75,7 @@ export async function getCategoryOverTime(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
topN: number = 50, topN: number = 50,
sourceId?: number, accountIds?: number[],
typeFilter?: "expense" | "income" | "transfer", typeFilter?: "expense" | "income" | "transfer",
): Promise<CategoryOverTimeData> { ): Promise<CategoryOverTimeData> {
const db = await getDb(); const db = await getDb();
@ -97,10 +100,11 @@ export async function getCategoryOverTime(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) { const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
whereClauses.push(`t.source_id = $${paramIndex}`); if (accountPlaceholders) {
params.push(sourceId); whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
paramIndex++; params.push(...accountIds!);
paramIndex += accountIds!.length;
} }
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
@ -183,12 +187,58 @@ export async function getCategoryOverTime(
categories.push("Other"); categories.push("Other");
} }
const data = Array.from(monthMap.values());
// --- Id-keyed hierarchical sidecar (Issue #264) ---
// The pivot above stays byte-identical (top-N + "Other", name-keyed) so the
// chart and dashboard are untouched. The tree is a SEPARATE view of the exact
// same `monthlyRows`, keyed by category_id, carrying EVERY category (no top-N,
// no "Other" bucket) so the trends table + its result lines are exact and two
// homonym categories of different types never collide.
const months = data.map((d) => d.month);
const monthIndex = new Map<string, number>();
months.forEach((m, i) => monthIndex.set(m, i));
// Aggregate every category into an id-keyed per-month magnitude series. A null
// category_id (truly uncategorized) collapses to one bucket; a non-null id that
// has no metadata row (hard-deleted) stays distinct and surfaces as an orphan.
const leafByKey = new Map<number | string, OverTimeRow>();
for (const row of monthlyRows) {
const key = row.category_id ?? "__uncategorized__";
let leaf = leafByKey.get(key);
if (!leaf) {
leaf = {
categoryId: row.category_id,
categoryName: row.category_name,
categoryColor: "#9ca3af",
monthly: new Array(months.length).fill(0),
total: 0,
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
leafByKey.set(key, leaf);
}
const idx = monthIndex.get(row.month);
if (idx != null) {
leaf.monthly[idx] += row.total;
leaf.total += row.total;
}
}
const cats = await db.select<TreeCatMeta[]>(
`SELECT id, name, color, type, parent_id FROM categories`,
);
const tree = buildOverTimeTree(Array.from(leafByKey.values()), cats ?? [], months.length);
return { return {
categories, categories,
data: Array.from(monthMap.values()), data,
colors, colors,
categoryIds, categoryIds,
types, types,
tree,
}; };
} }
@ -435,71 +485,118 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` }; return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
} }
// --- Compare hierarchy (Issue #247) --- // --- Generic leaf-driven tree builder (Issue #263) ---
/** Section key shared by every hierarchical report (income-statement order). */
export type TreeSectionType = "expense" | "income" | "transfer";
/** /**
* Minimal category metadata for building the compare tree. Fetched WITHOUT an * Minimal category metadata every hierarchical report needs: id, display
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry * name/color, income-statement `type`, and `parent_id` to walk the hierarchy.
* historic transactions keep their place in the hierarchy matching the raw * Shared by the compare tree (Issue #247) and the trends tree (Issue #264).
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
*/ */
interface CompareCatMeta { export interface TreeCatMeta {
id: number; id: number;
name: string; name: string;
color: string | null; color: string | null;
type: "expense" | "income" | "transfer" | null; type: TreeSectionType | null;
parent_id: number | null; parent_id: number | null;
} }
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
// Income statement reading order: revenues first, then expenses, then the
// (netted) transfers — the two result lines are injected around them in the UI.
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */ /** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
const MAX_TREE_DEPTH = 5; const MAX_TREE_DEPTH = 5;
/** /**
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a * Injection points that specialise the generic tree builder for a concrete row
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of * type `T`. `T` is BOTH the flat leaf payload fed in and the enriched row shape
* getBudgetVsActualData. * emitted leaf/subtotal rows just add the hierarchy fields (`parent_id`,
* * `is_parent`, `depth`, ) the caller writes in `makeLeaf` / `makeSubtotal`.
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
* returned, and a subtotal is the arithmetic sum of its descendant leaves so a
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
* net. Driven by `leaves` (the categories that actually had transactions in the
* window) rather than the full category list, so netted-to-zero transfers and
* soft-deleted categories with history are preserved and no empty rows appear.
*
* Exported for unit testing.
*/ */
export function buildCompareTree( export interface LeafDrivenTreeOptions<T> {
leaves: CategoryDelta[], /** Category hierarchy (fetched without an is_active filter — keeps history). */
categories: CompareCatMeta[], categories: TreeCatMeta[];
): CategoryDelta[] { /** Category a leaf belongs to; a null/absent id → orphan (top-level leaf). */
const catById = new Map<number, CompareCatMeta>(); categoryIdOf: (leaf: T) => number | null;
/** Enrich a leaf payload into a leaf row parented at `parentId` / `depth`. */
makeLeaf: (cat: TreeCatMeta, leaf: T, parentId: number | null, depth: number) => T;
/** Reduce a node's descendant leaves into its subtotal (is_parent) row. */
makeSubtotal: (
cat: TreeCatMeta,
descendantLeaves: T[],
parentId: number | null,
depth: number,
) => T;
/** Annotate the "(direct)" leaf of a parent that also has own transactions. */
decorateDirectLeaf: (leafRow: T, cat: TreeCatMeta) => T;
/** Enrich an orphan (Uncategorized / hard-deleted) payload into a root leaf. */
makeOrphan: (leaf: T) => T;
/** Magnitude used to order siblings (descending) — read off a block's head. */
sortKey: (row: T) => number;
/** True when a row is a subtotal, so descendant *leaves* can be summed. */
isSubtotal: (row: T) => boolean;
/** Section key of a row for the final contiguous type sort. */
sectionOf: (row: T) => string;
/** Section order, income-first e.g. `{ income: 0, expense: 1, transfer: 2 }`. */
sectionOrder: Record<string, number>;
/** Rank for a section absent from `sectionOrder` (default 9 — sorts last). */
unknownSectionRank?: number;
/** Cyclic-chain depth cap (default `MAX_TREE_DEPTH`). */
maxDepth?: number;
}
/**
* Generic leaf-driven hierarchy builder. Turns a flat list of per-category
* leaves into a parent/child tree with subtotal rows, ordered by magnitude
* within each type and grouped into contiguous income expense transfer
* sections. Driven by the leaves that actually exist (not the full category
* list), so netted-to-zero groups and soft-deleted categories with history are
* preserved and no empty rows appear.
*
* Behavior including the `MAX_TREE_DEPTH` guard and buildNode loop-break
* against a corrupted parent_id cycle is shared verbatim by every
* hierarchical report; the row arithmetic is injected via `opts`. Extracted
* from `buildCompareTree` (Issue #263) so the trends tree (Issue #264) reuses
* the exact same skeleton.
*/
export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<T>): T[] {
const {
categories,
categoryIdOf,
makeLeaf,
makeSubtotal,
decorateDirectLeaf,
makeOrphan,
sortKey,
isSubtotal,
sectionOf,
sectionOrder,
unknownSectionRank = 9,
maxDepth = MAX_TREE_DEPTH,
} = opts;
const catById = new Map<number, TreeCatMeta>();
for (const c of categories) catById.set(c.id, c); for (const c of categories) catById.set(c.id, c);
// Flat delta lookup by category id. Rows whose category id is null // Flat leaf lookup by category id. Rows whose id is null (Uncategorized) or
// (Uncategorized) or absent from the table (hard-deleted) become orphans: // absent from the table (hard-deleted) become orphans: depth-0 leaves
// depth-0 leaves preserved in their incoming order. // preserved in their incoming order.
const deltaByCat = new Map<number, CategoryDelta>(); const leafByCat = new Map<number, T>();
const orphans: CategoryDelta[] = []; const orphans: T[] = [];
for (const d of leaves) { for (const l of leaves) {
if (d.categoryId != null && catById.has(d.categoryId)) { const id = categoryIdOf(l);
deltaByCat.set(d.categoryId, d); if (id != null && catById.has(id)) {
leafByCat.set(id, l);
} else { } else {
orphans.push(d); orphans.push(l);
} }
} }
// "Relevant" = every category that has a delta plus all of its ancestors. // "Relevant" = every category that has a leaf plus all of its ancestors.
const relevant = new Set<number>(); const relevant = new Set<number>();
for (const id of deltaByCat.keys()) { for (const id of leafByCat.keys()) {
let cur: number | null | undefined = id; let cur: number | null | undefined = id;
let guard = 0; let guard = 0;
while (cur != null && guard <= MAX_TREE_DEPTH) { while (cur != null && guard <= maxDepth) {
if (relevant.has(cur)) break; if (relevant.has(cur)) break;
relevant.add(cur); relevant.add(cur);
cur = catById.get(cur)?.parent_id ?? null; cur = catById.get(cur)?.parent_id ?? null;
@ -508,7 +605,7 @@ export function buildCompareTree(
} }
// Adjacency among relevant categories only, preserving DB order. // Adjacency among relevant categories only, preserving DB order.
const childrenByParent = new Map<number, CompareCatMeta[]>(); const childrenByParent = new Map<number, TreeCatMeta[]>();
for (const c of categories) { for (const c of categories) {
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) { if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
let arr = childrenByParent.get(c.parent_id); let arr = childrenByParent.get(c.parent_id);
@ -517,26 +614,127 @@ export function buildCompareTree(
} }
} }
const typeOf = (c: CompareCatMeta): "expense" | "income" | "transfer" => c.type ?? "expense"; interface Block {
rows: T[];
sortKey: number; // magnitude of the block head — orders siblings
}
const leafRow = ( // Builds a node's block: a pure leaf, or a subtotal followed by its
cat: CompareCatMeta, // (recursively built) child blocks, siblings ordered by magnitude desc.
parentId: number | null, const buildNode = (cat: TreeCatMeta, depth: number): Block | null => {
depth: number, // Stop descending past the depth cap so a corrupted parent_id cycle can
): CategoryDelta => ({ // never recurse forever — deeper nodes collapse to leaves (real category
...deltaByCat.get(cat.id)!, // trees are ≤ 3 levels).
const children = depth >= maxDepth ? [] : (childrenByParent.get(cat.id) ?? []);
const hasDirect = leafByCat.has(cat.id);
if (children.length === 0) {
if (!hasDirect) return null;
const leaf = makeLeaf(cat, leafByCat.get(cat.id)!, cat.parent_id ?? null, depth);
return { rows: [leaf], sortKey: sortKey(leaf) };
}
const childBlocks: Block[] = [];
// A category with both children AND its own transactions surfaces the direct
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible
// rows. Rare: a parent auto-loses is_inputable when a child is added.
if (hasDirect) {
const direct = decorateDirectLeaf(
makeLeaf(cat, leafByCat.get(cat.id)!, cat.id, depth + 1),
cat,
);
childBlocks.push({ rows: [direct], sortKey: sortKey(direct) });
}
for (const child of children) {
const b = buildNode(child, depth + 1);
if (b) childBlocks.push(b);
}
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
const childRows = childBlocks.flatMap((b) => b.rows);
const descendantLeaves = childRows.filter((r) => !isSubtotal(r));
const subtotal = makeSubtotal(cat, descendantLeaves, cat.parent_id ?? null, depth);
return { rows: [subtotal, ...childRows], sortKey: sortKey(subtotal) };
};
// Roots = relevant categories with no relevant parent. Ordered by magnitude.
const rootBlocks: Block[] = [];
for (const c of categories) {
if (!relevant.has(c.id)) continue;
if (c.parent_id != null && relevant.has(c.parent_id)) continue;
const b = buildNode(c, 0);
if (b) rootBlocks.push(b);
}
rootBlocks.sort((a, b) => b.sortKey - a.sortKey);
const rows = rootBlocks.flatMap((b) => b.rows);
// Orphans (Uncategorized + hard-deleted) appended in their incoming order.
for (const l of orphans) rows.push(makeOrphan(l));
// Stable sort by section so types (income → expense → transfer) are
// contiguous; magnitude/tree order within a type is preserved via the index.
const order = new Map<T, number>();
rows.forEach((r, i) => order.set(r, i));
rows.sort((a, b) => {
const ta = sectionOrder[sectionOf(a)] ?? unknownSectionRank;
const tb = sectionOrder[sectionOf(b)] ?? unknownSectionRank;
if (ta !== tb) return ta - tb;
return order.get(a)! - order.get(b)!;
});
return rows;
}
// --- Compare hierarchy (Issue #247) ---
/**
* Minimal category metadata for building the compare tree. Fetched WITHOUT an
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry
* historic transactions keep their place in the hierarchy matching the raw
* LEFT JOIN behavior of compareDeltaSql() (no regression on the leaves shown).
*/
type CompareCatMeta = TreeCatMeta;
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
// Income statement reading order: revenues first, then expenses, then the
// (netted) transfers — the two result lines are injected around them in the UI.
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/**
* Turns the flat per-category deltas returned by compareDeltaSql() into a
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
* getBudgetVsActualData.
*
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
* returned, and a subtotal is the arithmetic sum of its descendant leaves so a
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
* net.
*
* A thin specialisation of `buildLeafDrivenTree` (Issue #263): the generic
* skeleton drives the hierarchy walk, sibling ordering, depth guard and section
* sort; this only supplies the delta arithmetic. Behavior is byte-identical to
* the previous hand-rolled builder the compare tests are the guard.
*
* Exported for unit testing.
*/
export function buildCompareTree(
leaves: CategoryDelta[],
categories: CompareCatMeta[],
): CategoryDelta[] {
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
return buildLeafDrivenTree<CategoryDelta>(leaves, {
categories,
categoryIdOf: (d) => d.categoryId,
makeLeaf: (cat, d, parentId, depth) => ({
...d,
parent_id: parentId, parent_id: parentId,
is_parent: false, is_parent: false,
depth, depth,
category_type: typeOf(cat), category_type: typeOf(cat),
}); }),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
const subtotalRow = (
cat: CompareCatMeta,
descendantLeaves: CategoryDelta[],
parentId: number | null,
depth: number,
): CategoryDelta => {
let previousAmount = 0; let previousAmount = 0;
let currentAmount = 0; let currentAmount = 0;
let cumulativePreviousAmount = 0; let cumulativePreviousAmount = 0;
@ -571,80 +769,96 @@ export function buildCompareTree(
depth, depth,
category_type: typeOf(cat), category_type: typeOf(cat),
}; };
}; },
// A parent with both children and its own transactions surfaces the direct
interface Block { // spend as a "(direct)" leaf (mirrors getBudgetVsActualData).
rows: CategoryDelta[]; decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
sortKey: number; // |monthly delta| of the block head — orders siblings // Orphans keep their own already-computed delta; only the hierarchy fields
} // are stamped. Uncategorized rows (category_type undefined) → 'expense'.
makeOrphan: (d) => ({
// Builds a node's block: a pure leaf, or a subtotal followed by its ...d,
// (recursively built) child blocks, siblings ordered by |monthly delta| desc. parent_id: null,
const buildNode = (cat: CompareCatMeta, depth: number): Block | null => { is_parent: false,
// Stop descending past the depth cap so a corrupted parent_id cycle can depth: 0,
// never recurse forever — deeper nodes collapse to leaves (real category category_type: d.category_type ?? "expense",
// trees are ≤ 3 levels). }),
const children = depth >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []); sortKey: (row) => Math.abs(row.deltaAbs),
const hasDirect = deltaByCat.has(cat.id); isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense",
if (children.length === 0) { sectionOrder: COMPARE_TYPE_ORDER,
if (!hasDirect) return null;
const leaf = leafRow(cat, cat.parent_id ?? null, depth);
return { rows: [leaf], sortKey: Math.abs(leaf.deltaAbs) };
}
const childBlocks: Block[] = [];
// A category with both children AND its own transactions surfaces the direct
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible
// rows (mirrors getBudgetVsActualData). Rare: a parent auto-loses
// is_inputable when a child is added.
if (hasDirect) {
const direct = leafRow(cat, cat.id, depth + 1);
childBlocks.push({
rows: [{ ...direct, categoryName: `${cat.name} (direct)` }],
sortKey: Math.abs(direct.deltaAbs),
}); });
} }
for (const child of children) {
const b = buildNode(child, depth + 1);
if (b) childBlocks.push(b);
}
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
const childRows = childBlocks.flatMap((b) => b.rows); // --- Trends hierarchy (Issue #264) ---
const descendantLeaves = childRows.filter((r) => !r.is_parent);
const subtotal = subtotalRow(cat, descendantLeaves, cat.parent_id ?? null, depth); /**
return { rows: [subtotal, ...childRows], sortKey: Math.abs(subtotal.deltaAbs) }; * Turns the flat per-category monthly leaves of `getCategoryOverTime` into a
* parent/child tree with subtotal (`is_parent`) rows the id-keyed sidecar to
* the name-keyed pivot. Another thin specialisation of `buildLeafDrivenTree`
* (Issue #263): the generic skeleton drives the hierarchy walk, sibling
* ordering, `MAX_TREE_DEPTH` cycle guard and the income expense transfer
* section sort (reusing `COMPARE_TYPE_ORDER`, income-first since #253); this only
* supplies the per-month magnitude arithmetic. A subtotal's `monthly[i]` is the
* sum of its descendant leaves' `monthly[i]`, so section subtotals and the
* result lines can be read straight off the leaves. `monthCount` fixes the
* length of every subtotal's series so it stays index-aligned with the pivot.
*
* Exported for unit testing.
*/
export function buildOverTimeTree(
leaves: OverTimeRow[],
categories: TreeCatMeta[],
monthCount: number,
): OverTimeRow[] {
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
return buildLeafDrivenTree<OverTimeRow>(leaves, {
categories,
categoryIdOf: (l) => l.categoryId,
makeLeaf: (cat, l, parentId, depth) => ({
...l,
categoryColor: cat.color ?? l.categoryColor,
parent_id: parentId,
is_parent: false,
depth,
category_type: typeOf(cat),
}),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
const monthly = new Array<number>(monthCount).fill(0);
let total = 0;
for (const dl of descendantLeaves) {
for (let i = 0; i < monthCount; i++) monthly[i] += dl.monthly[i] ?? 0;
total += dl.total;
}
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
monthly,
total,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
}; };
},
// Roots = relevant categories with no relevant parent. Ordered by magnitude. // A parent with both children and its own transactions surfaces the direct
const rootBlocks: Block[] = []; // spend as a "(direct)" leaf (mirrors buildCompareTree / getBudgetVsActualData).
for (const c of categories) { decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
if (!relevant.has(c.id)) continue; // Orphans (Uncategorized / hard-deleted) keep their own series; only the
if (c.parent_id != null && relevant.has(c.parent_id)) continue; // hierarchy fields are stamped. No metadata → default 'expense'.
const b = buildNode(c, 0); makeOrphan: (l) => ({
if (b) rootBlocks.push(b); ...l,
} parent_id: null,
rootBlocks.sort((a, b) => b.sortKey - a.sortKey); is_parent: false,
depth: 0,
const rows = rootBlocks.flatMap((b) => b.rows); category_type: l.category_type ?? "expense",
// Orphans (Uncategorized + hard-deleted) appended in their incoming order. }),
for (const d of orphans) { sortKey: (row) => Math.abs(row.total),
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" }); isSubtotal: (row) => row.is_parent === true,
} sectionOf: (row) => row.category_type ?? "expense",
sectionOrder: COMPARE_TYPE_ORDER,
// Stable sort by type so sections (income → expense → transfer) are
// contiguous; magnitude/tree order within a type is preserved via the index.
const order = new Map<CategoryDelta, number>();
rows.forEach((r, i) => order.set(r, i));
rows.sort((a, b) => {
const ta = COMPARE_TYPE_ORDER[a.category_type ?? "expense"] ?? 9;
const tb = COMPARE_TYPE_ORDER[b.category_type ?? "expense"] ?? 9;
if (ta !== tb) return ta - tb;
return order.get(a)! - order.get(b)!;
}); });
return rows;
} }
function previousMonth(year: number, month: number): { year: number; month: number } { function previousMonth(year: number, month: number): { year: number; month: number } {
@ -672,8 +886,16 @@ function previousMonth(year: number, month: number): { year: number; month: numb
* rows still contribute only their outflows, byte-identical to before. The two * rows still contribute only their outflows, byte-identical to before. The two
* result lines (before/after transfers) are computed in ComparePeriodTable from * result lines (before/after transfers) are computed in ComparePeriodTable from
* the per-type subtotals, not here. Uncategorized rows (c.type NULL) 'expense'. * the per-type subtotals, not here. Uncategorized rows (c.type NULL) 'expense'.
*
* `accountIds` (Issue #273) adds an optional `t.source_id IN (...)` clause
* one bound placeholder per id, starting at $9 (the 8 date bounds occupy
* $1-$8). Omitted/empty no clause at all, so the query text and results stay
* byte-identical to the pre-#273 behavior.
*/ */
const COMPARE_DELTA_SQL = `SELECT function compareDeltaSql(accountIds?: number[]): string {
const accountPlaceholders = inPlaceholders(accountIds, 9);
const accountFilter = accountPlaceholders ? ` AND t.source_id IN (${accountPlaceholders})` : "";
return `SELECT
t.category_id, t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(c.color, '#9ca3af') AS category_color,
@ -689,20 +911,23 @@ const COMPARE_DELTA_SQL = `SELECT
OR (t.date >= $3 AND t.date <= $4) OR (t.date >= $3 AND t.date <= $4)
OR (t.date >= $5 AND t.date <= $6) OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8) OR (t.date >= $7 AND t.date <= $8)
) )${accountFilter}
GROUP BY t.category_id, category_name, category_color GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`; ORDER BY ABS(month_current_total - month_previous_total) DESC`;
}
/** /**
* Month-over-month expense delta by category. Returns both a monthly view * Month-over-month expense delta by category. Returns both a monthly view
* (reference month vs immediately-previous month) and a cumulative YTD view * (reference month vs immediately-previous month) and a cumulative YTD view
* (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative * (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative
* progress through end of last month" vs "cumulative progress through end of * progress through end of last month" vs "cumulative progress through end of
* this month"). All SQL parameterised. * this month"). All SQL parameterised. `accountIds` (Issue #273) optionally
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/ */
export async function getCompareMonthOverMonth( export async function getCompareMonthOverMonth(
year: number, year: number,
month: number, month: number,
accountIds?: number[],
): Promise<CategoryDelta[]> { ): Promise<CategoryDelta[]> {
const db = await getDb(); const db = await getDb();
const { start: curStart, end: curEnd } = monthBoundaries(year, month); const { start: curStart, end: curEnd } = monthBoundaries(year, month);
@ -720,16 +945,19 @@ export async function getCompareMonthOverMonth(
const cumPreviousStart = `${prev.year}-01-01`; const cumPreviousStart = `${prev.year}-01-01`;
const cumPreviousEnd = prevEnd; const cumPreviousEnd = prevEnd;
// Delta select stays first so its params/SQL remain `mock.calls[0]`; the const deltaParams: unknown[] = [
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards
// under-specified mocks — db.select never returns undefined in production.
const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
curStart, curEnd, curStart, curEnd,
prevStart, prevEnd, prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd, cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd, cumPreviousStart, cumPreviousEnd,
]), ];
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
// Delta select stays first so its params/SQL remain `mock.calls[0]`; the
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards
// under-specified mocks — db.select never returns undefined in production.
const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL), db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]); ]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []); return buildCompareTree(rowsToDeltas(rows), cats ?? []);
@ -741,11 +969,13 @@ export async function getCompareMonthOverMonth(
* view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the * view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the
* reference year's December as the "current month" when no explicit * reference year's December as the "current month" when no explicit
* reference month is provided; callers typically pass the user's chosen * reference month is provided; callers typically pass the user's chosen
* reference month. All SQL parameterised. * reference month. All SQL parameterised. `accountIds` (Issue #273) optionally
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/ */
export async function getCompareYearOverYear( export async function getCompareYearOverYear(
year: number, year: number,
month: number = 12, month: number = 12,
accountIds?: number[],
): Promise<CategoryDelta[]> { ): Promise<CategoryDelta[]> {
const db = await getDb(); const db = await getDb();
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month); const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
@ -756,14 +986,17 @@ export async function getCompareYearOverYear(
const cumPreviousStart = `${year - 1}-01-01`; const cumPreviousStart = `${year - 1}-01-01`;
const cumPreviousEnd = prevMonthEnd; const cumPreviousEnd = prevMonthEnd;
// See getCompareMonthOverMonth: delta select first, categories alongside. const deltaParams: unknown[] = [
const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
curMonthStart, curMonthEnd, curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd, prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd, cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd, cumPreviousStart, cumPreviousEnd,
]), ];
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
// See getCompareMonthOverMonth: delta select first, categories alongside.
const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL), db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]); ]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []); return buildCompareTree(rowsToDeltas(rows), cats ?? []);
@ -953,18 +1186,25 @@ interface RawMonthFlow {
async function fetchMonthlyFlows( async function fetchMonthlyFlows(
dateFrom: string, dateFrom: string,
dateTo: string, dateTo: string,
accountIds?: number[],
): Promise<RawMonthFlow[]> { ): Promise<RawMonthFlow[]> {
const db = await getDb(); const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, 3);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawMonthFlow[]>( return db.select<RawMonthFlow[]>(
`SELECT `SELECT
strftime('%Y-%m', date) AS month, strftime('%Y-%m', date) AS month,
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income, COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
FROM transactions FROM transactions
WHERE date >= $1 AND date <= $2 WHERE date >= $1 AND date <= $2${accountClause}
GROUP BY month GROUP BY month
ORDER BY month ASC`, ORDER BY month ASC`,
[dateFrom, dateTo], params,
); );
} }
@ -977,9 +1217,16 @@ async function fetchSeasonality(
month: number, month: number,
yearFrom: number, yearFrom: number,
yearTo: number, yearTo: number,
accountIds?: number[],
): Promise<RawSeasonalityRow[]> { ): Promise<RawSeasonalityRow[]> {
const db = await getDb(); const db = await getDb();
const mm = String(month).padStart(2, "0"); const mm = String(month).padStart(2, "0");
const params: unknown[] = [mm, yearFrom, yearTo];
const accountPlaceholders = inPlaceholders(accountIds, 4);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawSeasonalityRow[]>( return db.select<RawSeasonalityRow[]>(
`SELECT `SELECT
CAST(strftime('%Y', date) AS INTEGER) AS year, CAST(strftime('%Y', date) AS INTEGER) AS year,
@ -987,10 +1234,10 @@ async function fetchSeasonality(
FROM transactions FROM transactions
WHERE strftime('%m', date) = $1 WHERE strftime('%m', date) = $1
AND CAST(strftime('%Y', date) AS INTEGER) >= $2 AND CAST(strftime('%Y', date) AS INTEGER) >= $2
AND CAST(strftime('%Y', date) AS INTEGER) <= $3 AND CAST(strftime('%Y', date) AS INTEGER) <= $3${accountClause}
GROUP BY year GROUP BY year
ORDER BY year DESC`, ORDER BY year DESC`,
[mm, yearFrom, yearTo], params,
); );
} }
@ -1006,11 +1253,19 @@ async function fetchSeasonality(
* via the monthly series instead of re-querying. * via the monthly series instead of re-querying.
* 4. Budget vs actual for the reference month. * 4. Budget vs actual for the reference month.
* 5. Seasonality: same calendar month across the two prior years. * 5. Seasonality: same calendar month across the two prior years.
*
* `accountIds` (Issues #273/#279) is forwarded to every sub-query so the whole
* dashboard honours an active account filter: the top-movers
* (`getCompareMonthOverMonth`) and budget (`getBudgetVsActualData`) sub-reports,
* and the KPI/sparkline/seasonality series (`fetchMonthlyFlows`,
* `fetchSeasonality`). Omitting it anywhere would let the dashboard silently mix
* filtered and unfiltered figures on the same screen.
*/ */
export async function getCartesSnapshot( export async function getCartesSnapshot(
referenceYear: number, referenceYear: number,
referenceMonth: number, referenceMonth: number,
mode: CartesKpiPeriodMode = "month", mode: CartesKpiPeriodMode = "month",
accountIds?: number[],
): Promise<CartesSnapshot> { ): Promise<CartesSnapshot> {
// Date window: 25 months back from the reference to cover YoY + a 13-month // Date window: 25 months back from the reference to cover YoY + a 13-month
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24. // sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
@ -1020,10 +1275,10 @@ export async function getCartesSnapshot(
// Seasonality range: previous 2 years for the same calendar month. // Seasonality range: previous 2 years for the same calendar month.
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([ const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1), fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1, accountIds),
fetchMonthlyFlows(windowStartIso, refEnd), fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
getCompareMonthOverMonth(referenceYear, referenceMonth), getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
getBudgetVsActualData(referenceYear, referenceMonth), getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
]); ]);
// Index the flow rows by month for O(1) lookup, then fill missing months // Index the flow rows by month for O(1) lookup, then fill missing months
@ -1177,7 +1432,7 @@ export async function getCartesSnapshot(
// Top movers: biggest MoM increases / decreases. `momRows` now carries the // Top movers: biggest MoM increases / decreases. `momRows` now carries the
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a // compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
// parent group can't double-count against its own leaves. Keep only EXPENSE // parent group can't double-count against its own leaves. Keep only EXPENSE
// leaves: since Issue #253 broadened COMPARE_DELTA_SQL to surface income (and // leaves: since Issue #253 broadened compareDeltaSql() to surface income (and
// net transfers), momRows now also carries revenue rows — and this card is a // net transfers), momRows now also carries revenue rows — and this card is a
// spending view whose colours read "up = red" (more spending is bad), so a // spending view whose colours read "up = red" (more spending is bad), so a
// salary rise must not appear under "biggest increases" in red. Mirror the // salary rise must not appear under "biggest increases" in red. Mirror the

View file

@ -285,6 +285,17 @@ export interface RecentTransaction {
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual"; export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
/**
* Shared filter state for the report pages: the active period plus the
* account (import source) subset. `accountIds` is a list of `ImportSource.id`
* not to be confused with the Bilan module's `balance_accounts` ids, a
* disjoint id space. An empty list means "no filter" (all accounts).
*/
export interface ReportFilters {
period: DashboardPeriod;
accountIds: number[];
}
export interface CategoryDelta { export interface CategoryDelta {
categoryId: number | null; categoryId: number | null;
categoryName: string; categoryName: string;
@ -367,6 +378,31 @@ export interface CategoryOverTimeItem {
[categoryName: string]: number | string; [categoryName: string]: number | string;
} }
/**
* One row of the id-keyed trends tree (Issue #264) the sidecar to the
* name-keyed pivot below. Carries a per-month magnitude series instead of a
* single delta, but mirrors `CategoryDelta`'s snake_case hierarchy block
* (`parent_id` / `is_parent` / `depth` / `category_type`) so it composes with
* `collapsibleRows` / `useCollapsibleGroups` without friction. Section/type is
* resolved by `category_id` (never by name), so two homonym categories of
* different types never collide the fix the name-keyed pivot cannot express.
*/
export interface OverTimeRow {
categoryId: number | null;
categoryName: string;
categoryColor: string;
/** Positive magnitude per month, index-aligned to `CategoryOverTimeData.data`. */
monthly: number[];
/** Sum of `monthly` — sibling ordering + row total convenience. */
total: number;
// Hierarchy block — same snake_case names as CategoryDelta (Issue #247/#264).
// `is_parent` rows are subtotals whose series = the sum of their leaf subtree.
parent_id: number | null;
is_parent: boolean;
depth: number;
category_type: "expense" | "income" | "transfer";
}
export interface CategoryOverTimeData { export interface CategoryOverTimeData {
categories: string[]; categories: string[];
data: CategoryOverTimeItem[]; data: CategoryOverTimeItem[];
@ -374,6 +410,13 @@ export interface CategoryOverTimeData {
categoryIds: Record<string, number | null>; categoryIds: Record<string, number | null>;
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */ /** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
types: Record<string, "expense" | "income" | "transfer">; types: Record<string, "expense" | "income" | "transfer">;
/**
* Id-keyed hierarchical sidecar (Issue #264): EVERY category (no top-N, no
* "Other" bucket), parent-first depth-first, income expense transfer.
* Drives the trends table + its result lines. The pivot fields above are left
* unchanged and still drive the (top-N-capped) chart and the dashboard.
*/
tree: OverTimeRow[];
} }
export interface BudgetVsActualRow { export interface BudgetVsActualRow {

View file

@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import {
type CollapseAccessors,
collapsibleKeys,
parseStoredExpanded,
serializeExpanded,
visibleRows,
} from "./collapsibleRows";
/** Tiny hierarchy row for the tests: key, depth, is-parent. */
interface Row {
k: string;
d: number;
p: boolean;
}
const acc: CollapseAccessors<Row> = {
keyOf: (r) => r.k,
depthOf: (r) => r.d,
isParent: (r) => r.p,
};
const row = (k: string, d: number, p: boolean): Row => ({ k, d, p });
// A two-group tree with one nested sub-group:
// Housing (parent, d0)
// Rent (leaf, d1)
// Utilities (parent, d1) <- intermediate parent
// Power (leaf, d2)
// Salary (parent, d0)
// Paycheck (leaf, d1)
// Misc (leaf, d0) <- top-level leaf, not a group
const tree: Row[] = [
row("housing", 0, true),
row("rent", 1, false),
row("utilities", 1, true),
row("power", 2, false),
row("salary", 0, true),
row("paycheck", 1, false),
row("misc", 0, false),
];
// Default = collapsed: a group is collapsed unless its key is in the expanded set.
const isCollapsed = (expanded: Set<string>) => (r: Row) => !expanded.has(r.k);
describe("collapsibleRows.visibleRows", () => {
it("collapses every group by default (empty expanded set)", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
// Only depth-0 rows survive: the two group headers + the top-level leaf.
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "misc"]);
});
it("reveals a group's full subtree (incl. nested sub-groups) when expanded", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing"])));
expect(visible.map((r) => r.k)).toEqual([
"housing",
"rent",
"utilities",
"power",
"salary",
"misc",
]);
});
it("keeps groups independent — expanding one leaves the others collapsed", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["salary"])));
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "paycheck", "misc"]);
});
it("shows everything when all groups are expanded", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"])));
expect(visible).toHaveLength(tree.length);
});
it("always keeps a top-level leaf that has no group", () => {
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
expect(visible.some((r) => r.k === "misc")).toBe(true);
});
});
describe("collapsibleRows.collapsibleKeys", () => {
it("lists only top-level parent rows, in order", () => {
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]);
});
it("returns [] when there is no hierarchy", () => {
const flat = [row("a", 0, false), row("b", 0, false)];
expect(collapsibleKeys(flat, acc)).toEqual([]);
});
});
describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
it("treats absent storage as the collapsed default (empty set)", () => {
expect(parseStoredExpanded(null).size).toBe(0);
expect(parseStoredExpanded("").size).toBe(0);
});
it("parses a JSON string array back into a set", () => {
expect([...parseStoredExpanded('["housing","salary"]')].sort()).toEqual(["housing", "salary"]);
});
it("falls back to empty on corrupt or non-array JSON", () => {
expect(parseStoredExpanded("{not json").size).toBe(0);
expect(parseStoredExpanded('{"a":1}').size).toBe(0);
expect(parseStoredExpanded("42").size).toBe(0);
});
it("drops non-string members defensively", () => {
expect([...parseStoredExpanded('["ok", 3, null, "yes"]')].sort()).toEqual(["ok", "yes"]);
});
it("round-trips through serialize", () => {
const set = new Set(["a", "b"]);
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]);
});
});

View file

@ -0,0 +1,86 @@
/**
* Collapse/expand of top-level category groups in the hierarchical comparable
* reports (real-vs-real Compare + Budget-vs-Actual) issue #254.
*
* Pure helpers only; the React state + persistence glue lives in the
* `useCollapsibleGroups` hook.
*
* A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps
* its own subtotal row visible but hides its entire subtree (every following
* depth 1 row until the next depth-0 row). Rows must arrive in depth-first
* order the order the compare/budget services already emit them in, and the
* same order `reorderRows` relies on.
*
* The accessors are passed in rather than read off fixed field names so each
* table can supply the *exact* depth expression it renders with (CategoryDelta
* uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from
* `parent_id`). Keeping the collapse depth and the indentation depth identical
* guarantees the hidden rows are exactly the indented descendants.
*/
/** How the collapse logic reads hierarchy position + identity off a row. */
export interface CollapseAccessors<T> {
/** Stable per-group key (a category id, stringified). */
keyOf: (row: T) => string;
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */
depthOf: (row: T) => number;
/** True when the row is a group header / subtotal (has children). */
isParent: (row: T) => boolean;
}
/**
* Keeps every row that is currently visible given which top-level groups are
* collapsed. `isCollapsed` is consulted only for top-level parent rows; a
* collapsed one keeps its own (subtotal) row and drops its whole subtree.
*/
export function visibleRows<T>(
rows: T[],
acc: CollapseAccessors<T>,
isCollapsed: (row: T) => boolean,
): T[] {
const out: T[] = [];
// While true we are inside a collapsed top-level parent's subtree and drop
// every deeper row until the next depth-0 row re-opens the flow.
let hidingSubtree = false;
for (const row of rows) {
if (acc.depthOf(row) === 0) {
out.push(row);
hidingSubtree = acc.isParent(row) && isCollapsed(row);
} else if (!hidingSubtree) {
out.push(row);
}
}
return out;
}
/** Keys of every collapsible group (top-level parent rows), in encounter order. */
export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] {
const keys: string[] = [];
for (const row of rows) {
if (acc.depthOf(row) === 0 && acc.isParent(row)) keys.push(acc.keyOf(row));
}
return keys;
}
/**
* Parses the persisted value into the set of *expanded* group keys. The report
* default is "everything collapsed" (issue #254/#260), so an absent or corrupt
* value yields an empty set i.e. all groups collapsed on a first-ever visit.
*/
export function parseStoredExpanded(raw: string | null): Set<string> {
if (!raw) return new Set();
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(parsed.filter((k): k is string => typeof k === "string"));
}
} catch {
// Corrupt value: fall back to the collapsed default.
}
return new Set();
}
/** Serialises the set of expanded group keys for persistence. */
export function serializeExpanded(expanded: Set<string>): string {
return JSON.stringify([...expanded]);
}

View file

@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { inPlaceholders } from "./sqlFilters";
describe("inPlaceholders", () => {
it("returns null for undefined ids (no filter)", () => {
expect(inPlaceholders(undefined, 1)).toBeNull();
});
it("returns null for an empty array (no filter)", () => {
expect(inPlaceholders([], 1)).toBeNull();
});
it("builds a single placeholder for one id", () => {
expect(inPlaceholders([7], 1)).toBe("$1");
});
it("builds one placeholder per id, starting at startIndex", () => {
expect(inPlaceholders([3, 7, 12], 4)).toBe("$4, $5, $6");
});
it("never joins the raw id values into the string (CWE-89 guard)", () => {
const result = inPlaceholders([3, 7], 1);
expect(result).not.toContain("3");
expect(result).not.toContain("7");
expect(result).toBe("$1, $2");
});
});

16
src/utils/sqlFilters.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* Shared helper for building a parameterized SQL `IN (...)` clause one
* bound placeholder per id, never a joined/interpolated value list (CWE-89).
* First introduced for the report services' optional account
* (`transactions.source_id`) filter (Issue #273); reused by every service
* that accepts an `accountIds?: number[]` filter so the placeholder
* bookkeeping (start index, one `$N` per id) lives in exactly one place.
*
* Returns `null` when `ids` is empty/undefined so callers can skip adding the
* clause entirely that is the "no filter" case, and the query must stay
* byte-identical to how it read before the filter existed.
*/
export function inPlaceholders(ids: number[] | undefined, startIndex: number): string | null {
if (!ids || ids.length === 0) return null;
return ids.map((_, i) => `$${startIndex + i}`).join(", ");
}