Compare commits

...

41 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
le king fu
f3ccb115ae chore: release v0.12.0
All checks were successful
Release / build-and-release (push) Successful in 25m5s
2026-07-05 20:59:03 -04:00
le king fu
450229522b polish(reports): drop dead category_type projection from the monthly query (#256 review)
getCategoryOverTime's monthly pivot selected COALESCE(c.type,'expense') AS
category_type but nothing read it — the `types` map is built from the top-N
query. Remove the unused projection and its row-type field. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:55:16 -04:00
le king fu
44976767d1 Merge PR #257: feat(reports) trend-by-category = income statement (#256)
# Conflicts:
#	.gitignore
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-07-05 20:53:11 -04:00
le king fu
1360940336 Merge PR #255: feat(reports) real-vs-real compare = income statement + previous-month default (#253) 2026-07-05 20:49:44 -04:00
le king fu
82040abc74 feat(reports): category-over-time table as result analysis (sections + net result per month)
All checks were successful
PR Check / rust (pull_request) Successful in 22m9s
PR Check / frontend (pull_request) Successful in 2m28s
Apply the compare report's "result analysis" presentation to Trends -> By
category (table view). Categories are grouped into Income -> Expenses ->
Transfers sections with per-month and total subtotals; the old mixed "Total"
row (which summed absolute magnitudes into a meaningless figure) is replaced by
a Net result row per month (income - expenses + transfers) plus a Result before
transfers row shown only when transfers exist, both coloured by sign. Category
cells stay neutral levels.

- reportService.getCategoryOverTime: project COALESCE(c.type,'expense') in both
  SELECTs and return an additive `types` map (built from the top-N rows, like
  `colors`). Dashboard widget and CategoryOverTimeChart are untouched.
- shared types: add `types` to CategoryOverTimeData.
- new pure module overTimeResults.ts (computeOverTimeResults) with unit tests -
  the per-month result reducer, mirroring the compare's pure Totals helper.
- i18n: add reports.compare.resultNet / resultBeforeTransfers (FR+EN); reuse the
  existing sections.* and total* keys.
- .gitignore: anchor the autopilot `reports/` rule to `/reports/` so it no
  longer swallows new files under src/components/reports/.

Section order is income-first (Revenus -> Depenses -> Transferts), per the
issue; the existing COMPARE_TYPE_ORDER is expense-first, so a local
OVER_TIME_TYPE_ORDER is used rather than reusing it.

Resolves #256

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:15:50 -04:00
le king fu
c7914c2ca5 fix(reports): exclude income from Cartes top movers (#253 regression)
All checks were successful
PR Check / rust (pull_request) Successful in 22m23s
PR Check / frontend (pull_request) Successful in 2m25s
/pr-review on #255 caught a cross-consumer regression: broadening
COMPARE_DELTA_SQL to surface income (this PR) also feeds getCartesSnapshot,
whose "top movers" card is a spending view (up = red). A salary rise would land
under "biggest increases" coloured red — inverted meaning.

Filter significantMovers to expense leaves ((category_type ?? "expense") ===
"expense"), mirroring ComparePeriodChart. The surviving expense output is
byte-identical to pre-#253. Add a Cartes regression test (an income category
with the biggest delta must not top the movers list) — the existing test mocked
the compare SQL without category types, so it stayed silently green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:14:23 -04:00
le king fu
d57b2563af fix(reports): StrictMode-safe period sync + review polish (#253)
All checks were successful
PR Check / rust (pull_request) Successful in 22m20s
PR Check / frontend (pull_request) Successful in 2m35s
Addresses /pr-review REQUEST_CHANGES on #255.

- useCompare: the "skip first sync" boolean was not StrictMode-safe — the dev
  double-invoke of effects flipped the flag on setup #1, so setup #2 re-synced
  the reference month to the civil-year December, re-introducing the very bug
  Changement 2 fixes (dev only; prod has no double-invoke). Replace it with a
  value-change guard: a ref seeded with the initial `to` plus a pure
  syncReferenceOnPeriodChange() that only dispatches when `to` actually changes.
  Idempotent across the double-invoke, and now unit-tested (5 cases) since the
  decision is a pure function (the project has no renderHook harness).
- Remove the now-orphaned reports.compare.totalRow i18n key (both locales) — the
  flat grand total it labelled was replaced by the result lines.
- ComparePeriodTable: gate the "before transfers" line on results.hasTransfers
  (previously computed/tested but unused).
- ComparePeriodChart: show the no-data empty state when the expense filter
  leaves nothing (a pure income/transfer period) instead of bare axes.

Build + 690 vitest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:37:22 -04:00
le king fu
f45845e408 feat(reports): real-vs-real compare as an income statement + previous-month default
All checks were successful
PR Check / rust (pull_request) Successful in 23m4s
PR Check / frontend (pull_request) Successful in 2m26s
The "actual vs actual" compare was expenses-only (WHERE amount < 0), so revenue
categories were invisible — you could not tell a surplus from a deficit. Make it
an income statement (the product's namesake "résultat"):

- COMPARE_DELTA_SQL: broaden the WHERE and per-type CASE to include `income` as
  a signed SUM (like `transfer`), so revenue credits survive the outflow filter.
  Expenses stay ABS-of-outflows, byte-identical. Transfer netting (#243) intact.
- Section order is now income -> expense -> transfer (COMPARE_TYPE_ORDER).
- ComparePeriodTable: replace the now-meaningless flat grand total with two
  result lines — "result before transfers" (revenues - expenses) and the net
  "result" (after transfers) — extracted into a pure, tested compareResults
  module. Delta colours are direction-aware (income/result up = green, spending
  up = red); result amounts are coloured by sign (surplus/deficit).
- ComparePeriodChart stays a spending view: filter to expense leaves so revenue
  bars don't mix into the same axis.
- useCompare: skip the initial period-sync so the compare opens on the previous
  (last complete) month instead of the civil-year December that useReportsPeriod
  yields by default.

Also anchor the `reports/` gitignore to `/reports/` — the unanchored rule was
silently ignoring new files under src/components/reports/.

Tests: new compareResults.test.ts (result roll-up, unbalanced transfer, deficit,
subtotal exclusion); reportService.test.ts updated for the broadened SQL, the
income bucket rule, and income-first section order. Build + 685 vitest green.

Resolves #253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:36:57 -04:00
le king fu
51531ec0ce state: sync after v0.11.0 ship (milestone overnight-2026-07-05-bilan-rapports-ux 5/5 closed) 2026-07-05 17:15:56 -04:00
57 changed files with 4757 additions and 1038 deletions

View file

@ -2,7 +2,7 @@
name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true
updated: 2026-04-19
updated: 2026-07-01
---
# /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)
- 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** :
- `CHANGELOG.md` (EN)
- `CHANGELOG.fr.md` (FR)
- 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.
- `CHANGELOG.md` (EN) : header `## [Unreleased]`
- `CHANGELOG.fr.md` (FR) : header `## [Non publié]` (PAS `[Unreleased]` — ne pas le confondre avec un changelog vide)
- 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`
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`) :
@ -48,3 +48,4 @@ updated: 2026-04-19
## 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-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.

5
.gitignore vendored
View file

@ -63,8 +63,9 @@ src-tauri/icons/ios/
.claude/worktrees/
decisions-log.md
# Autopilot scratch + daily reports
reports/
# Autopilot scratch + daily reports (repo root only — must not match
# src/components/reports/, which is real tracked source)
/reports/
# Spec scratch (committed only when promoted to docs/archive/)
spec-decisions-*.md

View file

@ -2,6 +2,37 @@
## [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
### Modifié
- Rapports → Comparaison (réel vs réel) : le rapport est désormais une **analyse de résultat**. Les catégories de revenu apparaissent aux côtés des dépenses, avec deux lignes de résultat — **résultat avant transferts** (revenus dépenses) et **résultat net** (après annulation des transferts) — pour voir d'un coup d'œil si la période dégage un surplus ou un déficit. Les catégories sont regroupées Revenus → Dépenses → Transferts, les couleurs suivent le sens (plus de revenu = vert, plus de dépense = rouge), et un montant de résultat passe au vert pour un surplus, au rouge pour un déficit (#253).
- Rapports : les rapports comparables s'ouvrent désormais par défaut sur le **mois précédent (dernier mois complet)** au lieu de décembre de l'année civile courante (#253).
- Rapports → Tendances → Par catégorie (vue tableau) : le tableau des catégories dans le temps se lit désormais comme une analyse de résultat, à l'image du rapport de comparaison. Les catégories sont regroupées en sections **Revenus → Dépenses → Transferts** avec des sous-totaux par mois et un total, et l'ancienne ligne « Total » mélangée — qui additionnait revenus, dépenses et transferts en valeurs absolues pour un chiffre sans signification — est remplacée par une ligne **Résultat net** par mois (revenus dépenses + transferts), colorée en vert pour un surplus et en rouge pour un déficit, plus une ligne **Résultat avant transferts** affichée lorsque des transferts existent. Les cellules de catégorie restent des niveaux neutres ; seules les lignes de résultat portent une couleur de signe (#256).
## [0.11.0] - 2026-07-05
### Ajouté

View file

@ -2,6 +2,37 @@
## [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
### Changed
- Reports → Compare (real vs real): the report is now an **income statement**. Revenue categories are shown alongside expenses, with two result lines — **result before transfers** (revenues expenses) and the **net result** (after netting transfers) — so you can see at a glance whether the period ran a surplus or a deficit. Categories are grouped Income → Expenses → Transfers, colours follow direction (more income is green, more spending is red), and a result amount turns green for a surplus, red for a deficit (#253).
- Reports: the comparable reports now open on the **previous (last complete) month** by default instead of the current civil year's December (#253).
- Reports → Trends → By category (table view): the category-over-time table now reads as a result analysis, mirroring the compare report. Categories are grouped into **Income → Expenses → Transfers** sections with per-month and total subtotals, and the old mixed "Total" row — which summed income, expenses, and transfers as absolute magnitudes into a meaningless figure — is replaced by a **Net result** row per month (income expenses + transfers), coloured green for a surplus and red for a deficit, plus a **Result before transfers** row shown when transfers exist. Category cells stay neutral levels; only the result rows carry a sign colour (#256).
## [0.11.0] - 2026-07-05
### Added

View file

@ -1,6 +1,6 @@
# STATE — Simpl'Résultat
> Derniere MAJ : 2026-07-04 (override `@babel/core` 7.29.7 mergé #241/PR #242`npm audit` désormais propre, 0 vuln)
> 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
@ -10,18 +10,23 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
**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
- 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-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-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-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)
- 2026-05-03 : Bilan post-merge cleanup (S1-S5+S7) — `getStarterCollisions` filtre `archived_at IS NULL`, in-txn re-check sur `proposeStarterAccounts`, pre-seed `balance_starter_proposed`, guard empty-state `/balance`, doctest fence `text` Modified Dietz (ref #187)
## 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`) |
| `adjustmentService.ts` | Gestion des ajustements |
| `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) |
| `dataExportService.ts` | Export de données (chiffré) |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
@ -205,8 +205,8 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useImportHistory` | Historique des imports |
| `useAdjustments` | Ajustements |
| `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
| `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) + 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 |
| `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) |
@ -350,7 +350,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| Route | Page | Description |
|-------|------|-------------|
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) |
| `/` | `DashboardPage` | Tableau de bord (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 |
| `/transactions` | `TransactionsPage` | Liste avec filtres |
| `/categories` | `CategoriesPage` | Gestion hiérarchique |

4
package-lock.json generated
View file

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

View file

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

2
src-tauri/Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
const fmt = new Intl.NumberFormat("en-CA", {
style: "currency",
@ -127,7 +128,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
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> = {
expense: "budget.expenses",
income: "budget.income",
@ -139,19 +142,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
transfer: "budget.totalTransfers",
};
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
const monthTotals: number[] = Array(12).fill(0);
let annualTotal = 0;
let prevYearTotal = 0;
for (const row of rows) {
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
const sign = signFor(row.category_type);
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
}
// Income-statement roll-up (Issue #278): Résultat avant transferts / net,
// computed on the budgeted + previous-year-actual leaves. Mathematically
// equivalent to the old plain grand-total (income expense + transfer over
// every leaf), now surfaced as the two labeled result rows below instead of
// a single unlabeled "Total".
const results = computeBudgetResults(rows);
const totalCols = 15; // category + prev year + annual + 12 months
@ -295,39 +291,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
);
};
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>
{typeOrder.map((type) => {
// One type's section: header, its (reorderable) rows, and a leaf-summed
// subtotal row. Extracted so it can be called for income/expense, then
// again for transfer once the Résultat rows are interleaved between them.
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
const group = grouped[type];
if (!group || group.length === 0) return null;
const sign = signFor(type);
@ -367,18 +334,76 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</tr>
</Fragment>
);
})}
{/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
{monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-3 px-2 text-right text-sm">
{formatSigned(total)}
};
// A Résultat row (avant-transferts subtotal, or the net bottom line).
// `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
// the softer variant mirrors the per-type section-subtotal row above.
const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
const rowClass = strong
? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
: "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>
))}
</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>
</table>
</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 { 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 { 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) =>
new Intl.NumberFormat("en-CA", {
@ -26,6 +29,17 @@ interface BudgetVsActualTableProps {
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) {
const { t } = useTranslation();
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) {
return (
<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
type SectionType = "expense" | "income" | "transfer";
// Group rows into contiguous type sections (the service already type-sorts:
// income → expense → transfer, Issue #277).
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
const typeLabels: Record<SectionType, string> = {
expense: t("budget.expenses"),
@ -72,25 +92,176 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
sections[sections.length - 1].rows.push(row);
}
// Grand totals (leaf rows only)
const leaves = data.filter((r) => !r.is_parent);
const totals = leaves.reduce(
(acc, r) => ({
monthActual: acc.monthActual + r.monthActual,
monthBudget: acc.monthBudget + r.monthBudget,
monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
// Income-statement result lines (Issue #277): income + expense (expense
// already arrives signed negative in BudgetVsActualRow — see
// budgetVsActualResults.ts), then the net after adding transfers. Replaces
// the old flat grand total row.
const results = computeResults(data);
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
const transferSection = sections.find((s) => s.type === "transfer");
const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => {
const sectionTotals = sumLeaves(section.rows);
const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget);
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 (
<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
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"
@ -141,130 +312,15 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr>
</thead>
<tbody>
{sections.map((section) => {
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
const sectionTotals = sectionLeaves.reduce(
(acc, r) => ({
monthActual: acc.monthActual + r.monthActual,
monthBudget: acc.monthBudget + r.monthBudget,
monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
);
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
return (
<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>
{nonTransferSections.map(renderSection)}
{/* Operating result (income + expense), shown before the transfers
section only when transfers exist otherwise it equals the net
total below and would just be noise (mirrors ComparePeriodTable). */}
{results.hasTransfers &&
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
{transferSection && renderSection(transferSection)}
{/* Bottom line: result after adding transfers. */}
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
</tbody>
</table>
</div>

View file

@ -59,6 +59,9 @@ export default function CategoryOverTimeChart({
const [hoveredCategory, setHoveredCategory] = useState<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 categoryEntries = visibleCategories.map((name, index) => ({
name,

View file

@ -1,5 +1,15 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import type { CategoryOverTimeData } from "../../shared/types";
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
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) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
@ -10,14 +20,40 @@ function formatMonth(month: string): string {
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
interface CategoryOverTimeTableProps {
data: CategoryOverTimeData;
hiddenCategories?: Set<string>;
/** Colours a result figure by sign: surplus green, deficit red, zero neutral. */
function resultColor(value: number): string {
if (value > 0) return "var(--positive, #10b981)";
if (value < 0) return "var(--negative, #ef4444)";
return "";
}
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
const SECTION_LABEL_KEY: Record<OverTimeType, string> = {
income: "reports.compare.sections.income",
expense: "reports.compare.sections.expenses",
transfer: "reports.compare.sections.transfers",
};
const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
income: "reports.compare.totalIncome",
expense: "reports.compare.totalExpenses",
transfer: "reports.compare.totalTransfers",
};
interface CategoryOverTimeTableProps {
data: CategoryOverTimeData;
}
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
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) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -26,14 +62,163 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
);
}
const visibleCategories = hiddenCategories?.size
? data.categories.filter((name) => !hiddenCategories.has(name))
: data.categories;
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
// the result lines are exact, and homonym categories never collide.
const analysis = computeOverTimeResults(data);
const { months, hasTransfers, net, beforeTransfers } = analysis;
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
const colSpan = months.length + 2;
const months = data.data.map((d) => d.month);
const hasGroups = groups.groupCount(data.tree) > 0;
const allExpanded = groups.allExpanded(data.tree);
// A section = its header, its (visibly) indented hierarchy rows, and a
// leaf-summed subtotal row. Only top-level parents collapse (parity with the
// comparable tables); the subtotal is always the reducer's leaf sum, so it
// never changes when a group is folded away.
const renderSection = (section: OverTimeRenderSection) => (
<Fragment key={section.type}>
{/* Section header */}
<tr className="bg-[var(--muted)]">
<td
colSpan={colSpan}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{t(SECTION_LABEL_KEY[section.type])}
</td>
</tr>
{/* Category rows — neutral leaf/parent magnitudes (not deltas) */}
{groups.visible(section.rows).map((row) => {
const isParent = row.is_parent;
const depth = row.depth;
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.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="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
)}
</td>
{months.map((month, monthIdx) => {
const value = row.monthly[monthIdx] ?? 0;
return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(row.total)}
</td>
</tr>
);
})}
{/* Section subtotal */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(SECTION_TOTAL_KEY[section.type])}
</td>
{months.map((month) => (
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
{cadFormatter(section.monthly[month])}
</td>
))}
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(section.total)}
</td>
</tr>
</Fragment>
);
// A result line (before-transfers subtotal or the net bottom line). Amounts are
// coloured by sign (surplus green / deficit red). Computed from the raw tree,
// so folding groups never moves these figures.
const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
<tr
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
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">
{t(labelKey)}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(series.monthly[month]) }}
>
{cadFormatter(series.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(series.total) }}
>
{cadFormatter(series.total)}
</td>
</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">
@ -52,57 +237,14 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
</tr>
</thead>
<tbody>
{visibleCategories.map((category) => {
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
return (
<tr key={category} className="border-b border-[var(--border)]/50">
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: data.colors[category] }}
/>
{category}
</span>
</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
return (
<td key={month} className="text-right px-3 py-1.5">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
{cadFormatter(rowTotal)}
</td>
</tr>
);
})}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const monthTotal = visibleCategories.reduce(
(sum, cat) => sum + ((monthData as Record<string, unknown>)?.[cat] as number || 0),
0,
);
return (
<td key={month} className="text-right px-3 py-3">
{cadFormatter(monthTotal)}
</td>
);
})}
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(
visibleCategories.reduce(
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
0,
),
)}
</td>
</tr>
{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>
</table>
</div>

View file

@ -7,9 +7,13 @@ import type { BudgetVsActualRow } from "../../shared/types";
export interface CompareBudgetViewProps {
year: 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 [rows, setRows] = useState<BudgetVsActualRow[]>([]);
const [error, setError] = useState<string | null>(null);
@ -17,7 +21,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
useEffect(() => {
let cancelled = false;
setError(null);
getBudgetVsActualData(year, month)
getBudgetVsActualData(year, month, accountIds)
.then((data) => {
if (!cancelled) setRows(data);
})
@ -27,7 +31,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
return () => {
cancelled = true;
};
}, [year, month]);
}, [year, month, accountIds]);
if (error) {
return (

View file

@ -32,21 +32,14 @@ export default function ComparePeriodChart({
}: ComparePeriodChartProps) {
const { t, i18n } = useTranslation();
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
// Sort by current-period amount (largest spending first) so the user's eye
// lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart. The table view groups by parent/child
// (Issue #247); the chart stays flat, so drop subtotal (is_parent) rows to
// avoid double-counting a group against its own leaves.
// The chart stays a spending view: the income-statement result (revenues +
// result lines, Issue #253) lives in the table, so keep only expense leaves
// here — mixing revenue bars into the same axis would misread. Drop subtotal
// (is_parent) rows too so a group is not double-counted against its own
// leaves. Sorted by current amount (largest spending first) for a top-down
// read of the biggest movers.
const chartData = rows
.filter((r) => !r.is_parent)
.filter((r) => !r.is_parent && (r.category_type ?? "expense") === "expense")
.sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({
name: r.categoryName,
@ -55,6 +48,16 @@ export default function ComparePeriodChart({
color: r.categoryColor,
}));
// Empty when there is no data at all, or when the period is pure
// income/transfers (no expense bars to draw) — avoid rendering bare axes.
if (chartData.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const previousFill = "var(--muted-foreground)";
const currentFill = "var(--primary)";

View file

@ -1,8 +1,11 @@
import { Fragment, useState } from "react";
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 { 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";
export interface ComparePeriodTableProps {
rows: CategoryDelta[];
@ -33,57 +36,40 @@ function formatSignedCurrency(amount: number, language: string): string {
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
function formatPct(pctValue: number | null, language: string): string {
if (pctValue === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}).format(pctValue / 100);
}
function variationColor(value: number): string {
// Compare report deals with expenses (abs values): spending more is negative
// for the user, spending less is positive. Mirror that colour convention
// consistently so the eye parses the delta sign at a glance.
if (value > 0) return "var(--negative, #ef4444)";
if (value < 0) return "var(--positive, #10b981)";
return "";
/**
* Delta colour, direction-aware (Issue #253). Expenses/transfers keep the
* spending convention (increase red, decrease green); income and the
* result lines invert it (higher is better green). Also used to colour a
* result *amount* by sign: a surplus is green, a deficit red.
*/
function deltaColor(value: number, higherIsBetter: boolean): string {
if (value === 0) return "";
const good = higherIsBetter ? value > 0 : value < 0;
return good ? "var(--positive, #10b981)" : "var(--negative, #ef4444)";
}
const STORAGE_KEY = "compare-subtotals-position";
type SectionType = "expense" | "income" | "transfer";
// 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,
};
/** Aggregate of the 6 comparable figures across a set of leaf rows. */
interface Totals {
monthCurrent: number;
monthPrevious: number;
monthDelta: number;
ytdCurrent: number;
ytdPrevious: number;
ytdDelta: number;
}
const COMPARE_EXPANDED_STORAGE_KEY = "reports-compare-expanded";
function sumLeaves(rows: CategoryDelta[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(acc, r) => ({
monthCurrent: acc.monthCurrent + r.currentAmount,
monthPrevious: acc.monthPrevious + r.previousAmount,
monthDelta: acc.monthDelta + r.deltaAbs,
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
}),
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
);
}
function pct(delta: number, previous: number): number | null {
return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null;
}
const COL_COUNT = 9;
export default function ComparePeriodTable({
rows,
@ -107,12 +93,19 @@ 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 monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Group rows into contiguous type sections (the service already type-sorts).
// Group rows into contiguous type sections (the service already type-sorts:
// income → expense → transfer).
const sectionLabels: Record<SectionType, string> = {
expense: t("reports.compare.sections.expenses"),
income: t("reports.compare.sections.income"),
@ -134,12 +127,235 @@ export default function ComparePeriodTable({
sections[sections.length - 1].rows.push(row);
}
// Grand totals across every leaf.
const totals = sumLeaves(rows);
// Income-statement result lines (Issue #253): revenues expenses, then the
// net after transfers. Replaces the old flat grand total, which is meaningless
// once revenues and (ABS) expenses share one table.
const results = computeResults(rows);
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
const transferSection = sections.find((s) => s.type === "transfer");
const renderSection = (section: { type: SectionType; rows: CategoryDelta[] }) => {
// Income section: an increase is good (green); expenses/transfers keep the
// spending convention (increase → red).
const higherIsBetter = section.type === "income";
const sectionTotals = sumLeaves(section.rows);
return (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<td
colSpan={COL_COUNT}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{sectionLabels[section.type]}
</td>
</tr>
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
const isParent = row.is_parent ?? false;
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 (
<tr
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="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
)}
</td>
{/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: deltaColor(row.deltaAbs, higherIsBetter) }}
>
{formatSignedCurrency(row.deltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: deltaColor(row.deltaAbs, higherIsBetter) }}
>
{formatPct(row.deltaPct, lang)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: deltaColor(row.cumulativeDeltaAbs, higherIsBetter) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: deltaColor(row.cumulativeDeltaAbs, higherIsBetter) }}
>
{formatPct(row.cumulativeDeltaPct, lang)}
</td>
</tr>
);
})}
{/* Section net total */}
<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(sectionTotalKeys[section.type])}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: deltaColor(sectionTotals.monthDelta, higherIsBetter) }}
>
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: deltaColor(sectionTotals.monthDelta, higherIsBetter) }}
>
{formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: deltaColor(sectionTotals.ytdDelta, higherIsBetter) }}
>
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: deltaColor(sectionTotals.ytdDelta, higherIsBetter) }}
>
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
</td>
</tr>
</Fragment>
);
};
// A result line (before-transfers subtotal or the net total). Higher is always
// better here: amounts are coloured by sign (surplus green / deficit red) and
// deltas by improvement.
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 tabular-nums";
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`}
style={{ color: deltaColor(tot.monthCurrent, true) }}
>
{formatCurrency(tot.monthCurrent, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.monthPrevious, true) }}>
{formatCurrency(tot.monthPrevious, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.monthDelta, true) }}>
{formatSignedCurrency(tot.monthDelta, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.monthDelta, true) }}>
{formatPct(pct(tot.monthDelta, tot.monthPrevious), lang)}
</td>
<td
className={`${cell} border-l border-[var(--border)]/50`}
style={{ color: deltaColor(tot.ytdCurrent, true) }}
>
{formatCurrency(tot.ytdCurrent, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.ytdPrevious, true) }}>
{formatCurrency(tot.ytdPrevious, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.ytdDelta, true) }}>
{formatSignedCurrency(tot.ytdDelta, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.ytdDelta, true) }}>
{formatPct(pct(tot.ytdDelta, tot.ytdPrevious), lang)}
</td>
</tr>
);
};
const hasGroups = groups.groupCount(rows) > 0;
const allExpanded = groups.allExpanded(rows);
return (
<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
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"
@ -205,187 +421,25 @@ export default function ComparePeriodTable({
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={9} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
<td colSpan={COL_COUNT} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
<>
{sections.map((section) => {
const sectionTotals = sumLeaves(section.rows);
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)]"
>
{sectionLabels[section.type]}
</td>
</tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent ?? false;
const depth = row.depth ?? 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.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)]`
}`}
>
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
{/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, lang)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, lang)}
</td>
</tr>
);
})}
{/* Section net total */}
<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(sectionTotalKeys[section.type])}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
</td>
</tr>
</Fragment>
);
})}
{/* Grand totals row */}
<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("reports.compare.totalRow")}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatSignedCurrency(totals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatPct(pct(totals.monthDelta, totals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatSignedCurrency(totals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatPct(pct(totals.ytdDelta, totals.ytdPrevious), lang)}
</td>
</tr>
{nonTransferSections.map(renderSection)}
{/* Operating result (revenues expenses), shown before the
transfers section only when transfers exist otherwise it
equals the net total below and would just be noise. */}
{results.hasTransfers &&
renderResultRow(
"reports.compare.resultBeforeTransfers",
results.resultBefore,
false,
)}
{transferSection && renderSection(transferSection)}
{/* Bottom line: result after netting transfers. */}
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
</>
)}
</tbody>

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

@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import type { CategoryDelta } from "../../shared/types";
import { computeResults, sumLeaves } from "./compareResults";
// Minimal leaf factory. Amounts arrive from COMPARE_DELTA_SQL already in the
// per-type sign convention (expenses = positive ABS, income/transfer = signed).
function leaf(
type: "expense" | "income" | "transfer",
mc: number,
mp: number,
cc = mc,
cp = mp,
): CategoryDelta {
return {
categoryId: 1,
categoryName: type,
categoryColor: "#000",
previousAmount: mp,
currentAmount: mc,
deltaAbs: mc - mp,
deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
cumulativePreviousAmount: cp,
cumulativeCurrentAmount: cc,
cumulativeDeltaAbs: cc - cp,
cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
category_type: type,
is_parent: false,
};
}
describe("computeResults — income-statement roll-up (Issue #253)", () => {
it("resultBefore = revenues expenses; resultNet adds net transfers", () => {
const r = computeResults([
leaf("income", 4350, 4500),
leaf("expense", 3900, 3700),
leaf("transfer", 0, 0),
]);
// Operating result: 4350 3900 = 450 current; 4500 3700 = 800 previous.
expect(r.resultBefore.monthCurrent).toBe(450);
expect(r.resultBefore.monthPrevious).toBe(800);
expect(r.resultBefore.monthDelta).toBe(-350);
// Balanced transfer (0) → net equals the operating result.
expect(r.resultNet.monthCurrent).toBe(450);
expect(r.hasTransfers).toBe(true);
});
it("an unbalanced (one-leg) 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.monthCurrent).toBe(1000);
// Net folds the residual transfer in: 1000 + (200) = 800.
expect(r.resultNet.monthCurrent).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.monthCurrent).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.monthCurrent).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.ytdCurrent).toBe(500);
expect(r.resultBefore.ytdPrevious).toBe(500);
});
it("sumLeaves adds only leaves", () => {
const t = sumLeaves([
leaf("expense", 100, 50),
{ ...leaf("expense", 999, 999), is_parent: true },
]);
expect(t.monthCurrent).toBe(100);
expect(t.monthPrevious).toBe(50);
});
});

View file

@ -0,0 +1,90 @@
import type { CategoryDelta } from "../../shared/types";
export type SectionType = "expense" | "income" | "transfer";
/** Aggregate of the 6 comparable figures across a set of leaf rows. */
export interface Totals {
monthCurrent: number;
monthPrevious: number;
monthDelta: number;
ytdCurrent: number;
ytdPrevious: number;
ytdDelta: number;
}
const ZERO: Totals = {
monthCurrent: 0,
monthPrevious: 0,
monthDelta: 0,
ytdCurrent: 0,
ytdPrevious: 0,
ytdDelta: 0,
};
/** Sum every non-subtotal (leaf) row into a single Totals. */
export function sumLeaves(rows: CategoryDelta[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(acc, r) => ({
monthCurrent: acc.monthCurrent + r.currentAmount,
monthPrevious: acc.monthPrevious + r.previousAmount,
monthDelta: acc.monthDelta + r.deltaAbs,
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
}),
{ ...ZERO },
);
}
export function pct(delta: number, previous: number): number | null {
return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null;
}
/** Component-wise a + sign*b across the 6 figures. */
function combine(a: Totals, b: Totals, sign: 1 | -1): Totals {
return {
monthCurrent: a.monthCurrent + sign * b.monthCurrent,
monthPrevious: a.monthPrevious + sign * b.monthPrevious,
monthDelta: a.monthDelta + sign * b.monthDelta,
ytdCurrent: a.ytdCurrent + sign * b.ytdCurrent,
ytdPrevious: a.ytdPrevious + sign * b.ytdPrevious,
ytdDelta: a.ytdDelta + sign * b.ytdDelta,
};
}
export interface CompareResults {
income: Totals;
expense: Totals;
transfer: Totals;
/** Revenues expenses: the operating result, before transfers. */
resultBefore: Totals;
/** resultBefore + net transfers: the bottom-line total. */
resultNet: Totals;
hasTransfers: boolean;
}
/**
* Income-statement roll-up for the real-vs-real compare (Issue #253).
*
* Sign convention flowing in from COMPARE_DELTA_SQL: expenses are positive
* magnitudes (ABS of outflows), income is a signed SUM (positive), transfers a
* signed net (~0 when balanced). So the operating result is a straight
* `income expense`, and the net adds the (usually ~0) transfer total. When a
* transfer is unbalanced (a single leg categorised) its residual shows up as
* the gap between resultBefore and resultNet.
*/
export function computeResults(rows: CategoryDelta[]): CompareResults {
const byType = (type: SectionType): Totals =>
sumLeaves(rows.filter((r) => (r.category_type ?? "expense") === type));
const income = byType("income");
const expense = byType("expense");
const transfer = byType("transfer");
const resultBefore = combine(income, expense, -1);
const resultNet = combine(resultBefore, transfer, 1);
const hasTransfers = rows.some(
(r) => (r.category_type ?? "expense") === "transfer" && !r.is_parent,
);
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
}

View file

@ -0,0 +1,195 @@
import { describe, it, expect } from "vitest";
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
/** Builds an id-keyed leaf row; `monthly` is index-aligned to the months list. */
function leaf(
categoryId: number | null,
categoryName: string,
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", () => {
it("is income-first (income → expense → transfer)", () => {
expect(OVER_TIME_TYPE_ORDER).toEqual({ income: 0, expense: 1, transfer: 2 });
});
});
describe("computeOverTimeResults", () => {
it("groups leaves into ordered sections with per-month + total subtotals", () => {
const data = makeData(
["2025-01", "2025-02"],
[
leaf(2, "Rent", [1000, 1000], "expense"),
leaf(4, "Savings", [200, 200], "transfer"),
leaf(1, "Salary", [3000, 3000], "income"),
leaf(3, "Groceries", [500, 700], "expense"),
],
);
const r = computeOverTimeResults(data);
expect(r.months).toEqual(["2025-01", "2025-02"]);
// Income-first ordering, regardless of the input row order.
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]);
const income = r.sections.find((s) => s.type === "income")!;
const expense = r.sections.find((s) => s.type === "expense")!;
const transfer = r.sections.find((s) => s.type === "transfer")!;
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 });
expect(income.total).toBe(6000);
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Rent", "Groceries"]);
expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 });
expect(expense.total).toBe(3200);
expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 });
expect(transfer.total).toBe(400);
});
it("computes net (income expense + transfer) and before-transfers per month", () => {
const data = makeData(
["2025-01", "2025-02"],
[
leaf(1, "Salary", [3000, 3000], "income"),
leaf(2, "Rent", [1000, 1000], "expense"),
leaf(3, "Groceries", [500, 700], "expense"),
leaf(4, "Savings", [200, 200], "transfer"),
],
);
const r = computeOverTimeResults(data);
expect(r.hasTransfers).toBe(true);
// before = income expense
expect(r.beforeTransfers.monthly).toEqual({ "2025-01": 1500, "2025-02": 1300 });
expect(r.beforeTransfers.total).toBe(2800);
// net = before + transfer
expect(r.net.monthly).toEqual({ "2025-01": 1700, "2025-02": 1500 });
expect(r.net.total).toBe(3200);
});
it("collapses net to before-transfers when there are no transfers", () => {
const data = makeData(
["2025-01"],
[leaf(1, "Salary", [2000], "income"), leaf(2, "Rent", [2500], "expense")],
);
const r = computeOverTimeResults(data);
expect(r.hasTransfers).toBe(false);
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
// Deficit: 2000 2500 = 500, identical for net and before-transfers.
expect(r.beforeTransfers.monthly["2025-01"]).toBe(-500);
expect(r.net.monthly["2025-01"]).toBe(-500);
expect(r.net.total).toBe(-500);
});
it("defaults a leaf with an unknown type to the expense section (defensive)", () => {
// The tree normally stamps a valid type on every leaf; the reducer still
// guards against a stray value by folding it into expenses.
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);
const expense = r.sections.find((s) => s.type === "expense")!;
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Other"]);
expect(expense.total).toBe(300);
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 300
});
it("drops empty sections and still nets a lone transfer section", () => {
const data = makeData(["2025-01"], [leaf(1, "Move", [100], "transfer")]);
const r = computeOverTimeResults(data);
expect(r.sections.map((s) => s.type)).toEqual(["transfer"]);
expect(r.hasTransfers).toBe(true);
expect(r.beforeTransfers.monthly["2025-01"]).toBe(0);
expect(r.net.monthly["2025-01"]).toBe(100);
});
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(
["2025-01"],
[leaf(1, "Divers", [1000], "income"), leaf(2, "Divers", [300], "expense")],
);
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")!;
// 500 + 300 = 800 (the parent's own 800 subtotal is excluded).
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", () => {
const data = makeData(
["2025-01", "2025-02"],
[leaf(1, "Salary", [1000, 1000], "income"), leaf(2, "Bonus", [0, 500], "income")],
);
const r = computeOverTimeResults(data);
const income = r.sections.find((s) => s.type === "income")!;
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });
expect(r.net.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });
expect(r.net.total).toBe(2500);
});
});

View file

@ -0,0 +1,116 @@
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
export type OverTimeType = "income" | "expense" | "transfer";
/**
* Income-statement reading order: revenue first, then expenses, then transfers,
* so the "net result" row reads naturally at the bottom (Income Expenses +
* Transfers). Matches the compare report's `COMPARE_TYPE_ORDER`, which is also
* income-first since issue #253 (the earlier expense-first note is obsolete).
*/
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
income: 0,
expense: 1,
transfer: 2,
};
/** A month -> value map plus the sum across all its months. */
export interface OverTimeSeries {
/** month (YYYY-MM) -> value */
monthly: Record<string, number>;
/** sum across every month */
total: number;
}
/**
* 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;
rows: OverTimeRow[];
/** month (YYYY-MM) -> section subtotal */
monthly: Record<string, number>;
/** sum across every month */
total: number;
}
export interface OverTimeAnalysis {
/** Ordered list of months (mirrors `data.data` order). */
months: string[];
/** Sections ordered income → expense → transfer; empty types are dropped. */
sections: OverTimeSection[];
/** True when at least one visible transfer-type category is present. */
hasTransfers: boolean;
/** Income Expenses + Transfers, per month (+ grand total). */
net: OverTimeSeries;
/** Income Expenses, per month (+ grand total). Only shown when `hasTransfers`. */
beforeTransfers: OverTimeSeries;
}
/**
* Pure reducer that turns the category-over-time report into an "income
* statement" shape: per-type sections with per-month subtotals, plus the
* net-result rows.
*
* Consumes the **id-keyed tree** (`data.tree`) NOT the name-keyed pivot so
* two homonym categories of different types never collide: each leaf carries its
* own `category_id`-resolved `category_type`, and the subtotals + result lines
* are summed over the tree's LEAVES only (never its `is_parent` subtotal rows,
* 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`.
*
* The tree is already ordered income expense transfer, so section ordering
* falls out of the leaves' own `category_type`.
*/
export function computeOverTimeResults(data: CategoryOverTimeData): OverTimeAnalysis {
const months = data.data.map((d) => d.month);
// Leaves only — subtotals (`is_parent`) are excluded so a parent group can
// never double-count against its own descendants.
const typeOf = (row: OverTimeRow): OverTimeType => {
const t = row.category_type;
return t === "income" || t === "transfer" ? t : "expense";
};
const byType: Record<OverTimeType, OverTimeRow[]> = { income: [], expense: [], transfer: [] };
for (const row of data.tree) {
if (!row.is_parent) byType[typeOf(row)].push(row);
}
const buildSection = (type: OverTimeType): OverTimeSection => {
const rows = byType[type];
const monthly: Record<string, number> = {};
let total = 0;
months.forEach((month, i) => {
const sum = rows.reduce((s, row) => s + (row.monthly[i] ?? 0), 0);
monthly[month] = sum;
total += sum;
});
return { type, rows, monthly, total };
};
const income = buildSection("income");
const expense = buildSection("expense");
const transfer = buildSection("transfer");
const hasTransfers = transfer.rows.length > 0;
const net: OverTimeSeries = { monthly: {}, total: 0 };
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
for (const month of months) {
const before = income.monthly[month] - expense.monthly[month];
const netMonth = before + transfer.monthly[month];
beforeTransfers.monthly[month] = before;
beforeTransfers.total += before;
net.monthly[month] = netMonth;
net.total += netMonth;
}
const sections = [income, expense, transfer]
.filter((s) => s.rows.length > 0)
.sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]);
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 type { BudgetYearRow, BudgetTemplate } from "../shared/types";
import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
import {
getAllActiveCategories,
getBudgetEntriesForYear,
@ -11,11 +11,14 @@ import {
applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
interface BudgetState {
year: number;
rows: BudgetYearRow[];
templates: BudgetTemplate[];
accounts: ImportSource[];
isLoading: boolean;
isSaving: boolean;
error: string | null;
@ -26,6 +29,7 @@ type BudgetAction =
| { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_YEAR"; payload: number };
function initialState(): BudgetState {
@ -33,6 +37,7 @@ function initialState(): BudgetState {
year: new Date().getFullYear(),
rows: [],
templates: [],
accounts: [],
isLoading: false,
isSaving: false,
error: null,
@ -54,6 +59,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
templates: action.payload.templates,
isLoading: false,
};
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_YEAR":
return { ...state, year: action.payload };
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() {
const { accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number) => {
const refreshData = useCallback(async (year: number, ids: number[]) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
@ -76,7 +87,7 @@ export function useBudget() {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
getAllActiveCategories(),
getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1),
getActualTotalsForYear(year - 1, ids),
getAllTemplates(),
]);
@ -364,8 +375,21 @@ export function useBudget() {
}, []);
useEffect(() => {
refreshData(state.year);
}, [state.year, refreshData]);
refreshData(state.year, accountIds);
}, [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) => {
dispatch({ type: "SET_YEAR", payload: state.year + delta });
@ -376,7 +400,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true });
try {
await upsertBudgetEntry(categoryId, state.year, month, amount);
await refreshData(state.year);
await refreshData(state.year, accountIds);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -386,7 +410,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
[state.year, accountIds, refreshData]
);
const splitEvenly = useCallback(
@ -400,7 +424,7 @@ export function useBudget() {
amounts.push(m < remainder ? base + 0.01 : base);
}
await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
await refreshData(state.year);
await refreshData(state.year, accountIds);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -410,7 +434,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
[state.year, accountIds, refreshData]
);
const saveTemplate = useCallback(
@ -423,7 +447,7 @@ export function useBudget() {
.filter((r) => !r.is_parent && r.months[0] !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year);
await refreshData(state.year, accountIds);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -433,7 +457,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.rows, state.year, refreshData]
[state.rows, state.year, accountIds, refreshData]
);
const applyTemplate = useCallback(
@ -441,7 +465,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true });
try {
await applyTemplateSvc(templateId, state.year, month);
await refreshData(state.year);
await refreshData(state.year, accountIds);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -451,7 +475,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
[state.year, accountIds, refreshData]
);
const applyTemplateAllMonths = useCallback(
@ -461,7 +485,7 @@ export function useBudget() {
for (let m = 1; m <= 12; m++) {
await applyTemplateSvc(templateId, state.year, m);
}
await refreshData(state.year);
await refreshData(state.year, accountIds);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -471,7 +495,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
[state.year, accountIds, refreshData]
);
const deleteTemplate = useCallback(
@ -479,7 +503,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteTemplateSvc(templateId);
await refreshData(state.year);
await refreshData(state.year, accountIds);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -489,7 +513,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
[state.year, accountIds, refreshData]
);
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,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
import {
previousMonth,
defaultReferencePeriod,
comparisonMeta,
syncReferenceOnPeriodChange,
} from "./useCompare";
describe("useCompare helpers", () => {
describe("previousMonth", () => {
@ -44,4 +49,40 @@ describe("useCompare helpers", () => {
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
});
});
describe("syncReferenceOnPeriodChange", () => {
// The ref is seeded with the initial `to`, so the mount call has to===last.
it("no-ops on mount, preserving the previous-month default", () => {
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-12-31", 2026, 6);
expect(r.next).toBeNull();
expect(r.lastSyncedTo).toBe("2026-12-31");
});
// StrictMode double-invokes effects in dev: calling twice with the same `to`
// must not sync (a fire-once boolean would wrongly snap to December on run 2).
it("is idempotent across a repeated `to` (StrictMode-safe)", () => {
const a = syncReferenceOnPeriodChange("2026-12-31", "2026-12-31", 2026, 6);
const b = syncReferenceOnPeriodChange(a.lastSyncedTo, "2026-12-31", 2026, 6);
expect(a.next).toBeNull();
expect(b.next).toBeNull();
});
it("syncs the reference month when `to` changes (user navigation)", () => {
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-06-30", 2026, 12);
expect(r.next).toEqual({ year: 2026, month: 6 });
expect(r.lastSyncedTo).toBe("2026-06-30");
});
it("records a changed `to` but does not dispatch when it already matches state", () => {
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-06-30", 2026, 6);
expect(r.next).toBeNull();
expect(r.lastSyncedTo).toBe("2026-06-30");
});
it("ignores an unparseable `to`", () => {
const r = syncReferenceOnPeriodChange("2026-12-31", "garbage", 2026, 6);
expect(r.next).toBeNull();
expect(r.lastSyncedTo).toBe("garbage");
});
});
});

View file

@ -1,6 +1,7 @@
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 { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
@ -13,6 +14,7 @@ interface State {
year: number;
month: number;
rows: CategoryDelta[];
accounts: ImportSource[];
isLoading: boolean;
error: string | null;
}
@ -23,6 +25,7 @@ type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string };
/**
@ -60,6 +63,32 @@ export function comparisonMeta(
return { previousYear: year - 1, previousMonth: month };
}
/**
* Pure decision for the URL-period reference-month sync effect.
*
* Gates on a *change* of `to` (the period's upper bound) rather than a
* fire-once flag: seeded with the initial `to`, the mount is a no-op, so the
* previous-month default from initialState survives instead of snapping to the
* civil-year December that useReportsPeriod yields by default (Issue #253).
* Because it keys off a value change, it is idempotent under React StrictMode's
* dev double-invoke of effects called twice with the same `to` it dispatches
* nothing the second time (a fire-once boolean would wrongly sync on run #2).
*
* Returns the `to` to remember plus an optional reference period to dispatch.
*/
export function syncReferenceOnPeriodChange(
lastSyncedTo: string,
to: string,
currentYear: number,
currentMonth: number,
): { lastSyncedTo: string; next: { year: number; month: number } | null } {
if (to === lastSyncedTo) return { lastSyncedTo, next: null };
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return { lastSyncedTo: to, next: null };
if (y === currentYear && m === currentMonth) return { lastSyncedTo: to, next: null };
return { lastSyncedTo: to, next: { year: y, month: m } };
}
const defaultRef = defaultReferencePeriod();
const initialState: State = {
mode: "actual",
@ -67,6 +96,7 @@ const initialState: State = {
year: defaultRef.year,
month: defaultRef.month,
rows: [],
accounts: [],
isLoading: false,
error: null,
};
@ -83,6 +113,8 @@ function reducer(state: State, action: Action): State {
return { ...state, isLoading: action.payload };
case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
@ -91,20 +123,26 @@ function reducer(state: State, action: Action): State {
}
export function useCompare() {
const { from, to } = useReportsPeriod();
const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
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
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const rows =
subMode === "mom"
? await getCompareMonthOverMonth(year, month)
: await getCompareYearOverYear(year, month);
? await getCompareMonthOverMonth(year, month, ids)
: await getCompareYearOverYear(year, month, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) {
@ -116,18 +154,37 @@ export function useCompare() {
);
useEffect(() => {
fetch(state.mode, state.subMode, state.year, state.month);
}, [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, accountIds]);
// When the URL period changes, align the reference month with `to`.
// The explicit dropdown remains the primary selector — this effect only
// keeps the two in sync when the user navigates via PeriodSelector.
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Transactions source filters — see useTrends.ts).
useEffect(() => {
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
if (y !== state.year || m !== state.month) {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
(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
// via PeriodSelector — but not on mount. The ref is seeded with the initial
// `to`, so syncReferenceOnPeriodChange only fires on an actual change of `to`,
// leaving the previous-month default from initialState intact (Issue #253) and
// staying idempotent under StrictMode's dev double-invoke.
const lastSyncedToRef = useRef(to);
useEffect(() => {
const { lastSyncedTo, next } = syncReferenceOnPeriodChange(
lastSyncedToRef.current,
to,
state.year,
state.month,
);
lastSyncedToRef.current = lastSyncedTo;
if (next) dispatch({ type: "SET_REFERENCE_PERIOD", payload: next });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]);

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 type {
DashboardPeriod,
DashboardSummary,
CategoryBreakdownItem,
CategoryOverTimeData,
BudgetVsActualRow,
CartesSnapshot,
ImportSource,
} from "../shared/types";
import {
getDashboardSummary,
getExpensesByCategory,
deriveNetWorthTile,
type NetWorthTileData,
} from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService";
import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import {
getSnapshotTotalsByDate,
listSnapshots,
listBalanceAccounts,
} from "../services/balance.service";
import { computeDateRange } from "../utils/dateRange";
import { defaultReferencePeriod } from "../utils/referencePeriod";
interface DashboardState {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[];
cartesSnapshot: CartesSnapshot | null;
period: DashboardPeriod;
budgetYear: number;
budgetMonth: number;
customDateFrom: string;
customDateTo: string;
referenceYear: number;
referenceMonth: number;
accountIds: number[];
accounts: ImportSource[];
netWorth: NetWorthTileData;
isLoading: boolean;
error: string | null;
}
@ -34,30 +68,46 @@ type DashboardAction =
| {
type: "SET_DATA";
payload: {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[];
cartesSnapshot: CartesSnapshot;
};
}
| { 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 todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
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 = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetVsActual: [],
categoryOverTime: EMPTY_CATEGORY_OVER_TIME,
cartesSnapshot: null,
period: "year",
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
customDateFrom: yearStartStr,
customDateTo: todayStr,
referenceYear: defaultRef.year,
referenceMonth: defaultRef.month,
accountIds: [],
accounts: [],
netWorth: EMPTY_NET_WORTH,
isLoading: false,
error: null,
};
@ -71,18 +121,28 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
case "SET_DATA":
return {
...state,
summary: action.payload.summary,
categoryBreakdown: action.payload.categoryBreakdown,
categoryOverTime: action.payload.categoryOverTime,
budgetVsActual: action.payload.budgetVsActual,
cartesSnapshot: action.payload.cartesSnapshot,
isLoading: false,
};
case "SET_PERIOD":
return { ...state, period: action.payload };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
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:
return state;
}
@ -96,8 +156,9 @@ export function useDashboard() {
period: DashboardPeriod,
customFrom: string | undefined,
customTo: string | undefined,
bYear: number,
bMonth: number,
refYear: number,
refMonth: number,
accountIds: number[],
) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
@ -105,15 +166,17 @@ export function useDashboard() {
try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
getDashboardSummary(dateFrom, dateTo),
getExpensesByCategory(dateFrom, dateTo),
getCategoryOverTime(dateFrom, dateTo),
getBudgetVsActualData(bYear, bMonth),
const [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([
getExpensesByCategory(dateFrom, dateTo, accountIds),
// typeFilter "expense" (Issue #279): this chart is titled "expenses
// over time" but previously aggregated every category type — a
// 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;
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({
@ -124,8 +187,59 @@ export function useDashboard() {
}, []);
useEffect(() => {
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
fetchData(
state.period,
state.customDateFrom,
state.customDateTo,
state.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) => {
dispatch({ type: "SET_PERIOD", payload: period });
@ -135,9 +249,13 @@ export function useDashboard() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
const setBudgetMonth = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
const setReferencePeriod = useCallback((year: number, month: number) => {
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 { resolveReportsPeriod } from "./useReportsPeriod";
import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } from "./useReportsPeriod";
describe("resolveReportsPeriod", () => {
const fixedToday = new Date("2026-04-14T12:00:00Z");
@ -51,3 +51,76 @@ describe("resolveReportsPeriod", () => {
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` };
}
// 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
* logic hookless and testable without rendering a router.
@ -57,15 +94,19 @@ export interface UseReportsPeriodResult {
from: string;
to: string;
period: DashboardPeriod;
accountIds: number[];
setPeriod: (period: DashboardPeriod) => 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
* bookmarkable and shared across the four report sub-routes.
* Reads/writes the active reporting period and, additively, the account
* (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 {
const [searchParams, setSearchParams] = useSearchParams();
@ -73,12 +114,15 @@ export function useReportsPeriod(): UseReportsPeriodResult {
const rawPeriod = searchParams.get("period");
const rawFrom = searchParams.get("from");
const rawTo = searchParams.get("to");
const rawSources = searchParams.get("sources");
const { from, to, period } = useMemo(
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
[rawPeriod, rawFrom, rawTo],
);
const accountIds = useMemo(() => parseAccountIds(rawSources), [rawSources]);
const setPeriod = useCallback(
(next: DashboardPeriod) => {
setSearchParams(
@ -115,5 +159,24 @@ export function useReportsPeriod(): UseReportsPeriodResult {
[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 type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
export type TrendsSubView = "global" | "byCategory";
@ -9,6 +10,7 @@ interface State {
subView: TrendsSubView;
monthlyTrends: MonthlyTrendItem[];
categoryOverTime: CategoryOverTimeData;
accounts: ImportSource[];
isLoading: boolean;
error: string | null;
}
@ -18,12 +20,14 @@ type Action =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string };
const initialState: State = {
subView: "global",
subView: "byCategory",
monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
accounts: [],
isLoading: false,
error: null,
};
@ -38,6 +42,8 @@ function reducer(state: State, action: Action): State {
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
@ -46,20 +52,21 @@ function reducer(state: State, action: Action): State {
}
export function useTrends() {
const { from, to } = useReportsPeriod();
const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
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;
dispatch({ type: "SET_LOADING", payload: true });
try {
if (subView === "global") {
const data = await getMonthlyTrends(dateFrom, dateTo);
const data = await getMonthlyTrends(dateFrom, dateTo, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo);
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
}
@ -67,11 +74,26 @@ export function useTrends() {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
},
[]
);
useEffect(() => {
fetch(state.subView, from, to);
}, [fetch, state.subView, from, to]);
fetch(state.subView, from, to, accountIds);
}, [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) => {
dispatch({ type: "SET_SUBVIEW", payload: sv });

View file

@ -20,7 +20,7 @@
},
"dashboard": {
"title": "Dashboard",
"balance": "Balance",
"kpiSectionTitle": "Key indicators",
"income": "Income",
"expenses": "Expenses",
"net": "Net",
@ -50,10 +50,11 @@
"help": {
"title": "How to use the Dashboard",
"tips": [
"Use the period selector (top right) to view different time ranges",
"Summary cards show your balance, income, and expenses for the selected period",
"The pie chart breaks down your expenses by category",
"Recent transactions are listed at the bottom"
"The filter bar at the top picks the date range and which import sources to include",
"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 \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source",
"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",
"subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom",
"collapse": {
"expandAll": "Expand all",
"collapseAll": "Collapse all"
},
"detail": {
"showAmounts": "Show amounts",
"hideAmounts": "Hide amounts"
@ -388,15 +393,18 @@
"search": "Search...",
"all": "All",
"none": "None",
"allTypes": "All types"
"allTypes": "All types",
"accounts": {
"label": "Import sources",
"hint": "No selection = all sources"
}
},
"bva": {
"monthly": "Monthly",
"ytd": "Year-to-Date",
"dollarVar": "$ Var",
"pctVar": "% Var",
"noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for"
"noData": "No budget or transaction data for this period."
},
"export": "Export",
"month": "Month",
@ -427,7 +435,6 @@
"referenceMonth": "Reference month",
"currentAmount": "Current",
"previousAmount": "Previous",
"totalRow": "Total",
"sections": {
"expenses": "Expenses",
"income": "Income",
@ -435,7 +442,9 @@
},
"totalExpenses": "Total Expenses",
"totalIncome": "Total Income",
"totalTransfers": "Total Transfers"
"totalTransfers": "Total Transfers",
"resultBeforeTransfers": "Result before transfers",
"resultNet": "Net result"
},
"cartes": {
"kpiSectionAria": "Key indicators for the reference month",

View file

@ -20,7 +20,7 @@
},
"dashboard": {
"title": "Tableau de bord",
"balance": "Solde",
"kpiSectionTitle": "Indicateurs clés",
"income": "Revenus",
"expenses": "Dépenses",
"net": "Net",
@ -50,10 +50,11 @@
"help": {
"title": "Comment utiliser le tableau de bord",
"tips": [
"Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates",
"Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée",
"Le graphique circulaire détaille vos dépenses par catégorie",
"Les transactions récentes sont listées en bas de page"
"Le filtre en haut choisit la plage de dates et les sources d'import à inclure",
"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",
"La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import",
"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",
"subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas",
"collapse": {
"expandAll": "Tout déplier",
"collapseAll": "Tout replier"
},
"detail": {
"showAmounts": "Afficher les montants",
"hideAmounts": "Masquer les montants"
@ -388,15 +393,18 @@
"search": "Rechercher...",
"all": "Toutes",
"none": "Aucune",
"allTypes": "Tous les types"
"allTypes": "Tous les types",
"accounts": {
"label": "Sources d'import",
"hint": "Aucune sélection = toutes les sources"
}
},
"bva": {
"monthly": "Mensuel",
"ytd": "Cumul annuel",
"dollarVar": "$ Écart",
"pctVar": "% Écart",
"noData": "Aucune donnée de budget ou de transaction pour cette période.",
"titlePrefix": "Budget vs Réel pour le mois de"
"noData": "Aucune donnée de budget ou de transaction pour cette période."
},
"export": "Exporter",
"month": "Mois",
@ -427,7 +435,6 @@
"referenceMonth": "Mois de référence",
"currentAmount": "Courant",
"previousAmount": "Précédent",
"totalRow": "Total",
"sections": {
"expenses": "Dépenses",
"income": "Revenus",
@ -435,7 +442,9 @@
},
"totalExpenses": "Total des dépenses",
"totalIncome": "Total des revenus",
"totalTransfers": "Total des transferts"
"totalTransfers": "Total des transferts",
"resultBeforeTransfers": "Résultat avant transferts",
"resultNet": "Résultat net"
},
"cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence",

View file

@ -1,12 +1,15 @@
import { useTranslation } from "react-i18next";
import { PageHelp } from "../components/shared/PageHelp";
import FilterPanel from "../components/reports/FilterPanel";
import { useBudget } from "../hooks/useBudget";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import YearNavigator from "../components/budget/YearNavigator";
import BudgetTable from "../components/budget/BudgetTable";
import TemplateActions from "../components/budget/TemplateActions";
export default function BudgetPage() {
const { t } = useTranslation();
const { accountIds, setAccountIds } = useReportsPeriod();
const {
state,
navigateYear,
@ -18,16 +21,15 @@ export default function BudgetPage() {
deleteTemplate,
} = useBudget();
const { year, rows, templates, isLoading, isSaving, error } = state;
const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
return (
<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">
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
<PageHelp helpKey="budget" />
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<TemplateActions
templates={templates}
onApply={applyTemplate}
@ -36,10 +38,15 @@ export default function BudgetPage() {
onDelete={deleteTemplate}
disabled={isSaving}
/>
<YearNavigator year={year} onNavigate={navigateYear} />
</div>
</div>
<FilterPanel
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
{error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{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 { useTranslation } from "react-i18next";
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { useDashboard } from "../hooks/useDashboard";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
import CategoryBarChart from "../components/reports/CategoryBarChart";
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 BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem } from "../shared/types";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
export default function DashboardPage() {
const { t, i18n } = useTranslation();
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
const {
state,
setPeriod,
setCustomDates,
setReferencePeriod,
setAccountIds,
} = useDashboard();
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -37,35 +54,6 @@ export default function DashboardPage() {
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 { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
@ -73,11 +61,19 @@ export default function DashboardPage() {
return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<CategoriesV1DiscoveryBanner />
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="relative flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
<PageHelp helpKey="dashboard" />
</div>
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}
</div>
)}
<FilterPanel
temporalControl={
<PeriodSelector
value={period}
onChange={setPeriod}
@ -85,46 +81,21 @@ export default function DashboardPage() {
customDateTo={state.customDateTo}
onCustomDateChange={setCustomDates}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{cards.map((card) => (
<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}
}
accountIds={state.accountIds}
onAccountIdsChange={setAccountIds}
accounts={state.accounts}
/>
</div>
<div className="lg:col-span-3">
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap">
{t("reports.bva.titlePrefix")}
<div className="flex items-center gap-2 mb-3 flex-wrap">
<h2 className="text-lg font-semibold">{t("dashboard.kpiSectionTitle")}</h2>
<select
value={`${state.budgetYear}-${state.budgetMonth}`}
value={`${state.referenceYear}-${state.referenceMonth}`}
onChange={(e) => {
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) => (
<option key={opt.key} value={opt.value}>
@ -132,9 +103,75 @@ export default function DashboardPage() {
</option>
))}
</select>
</h2>
<BudgetVsActualTable data={budgetVsActual} />
</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 className="mb-6">

View file

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

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
@ -21,9 +22,9 @@ const STORAGE_KEY = "reports-viewmode-trends";
export default function ReportsTrendsPage() {
const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
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>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
<FilterPanel
temporalControl={
<PeriodSelector
value={period}
onChange={setPeriod}
@ -63,7 +65,13 @@ export default function ReportsTrendsPage() {
customDateTo={to}
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">
<button
type="button"
@ -99,7 +107,6 @@ export default function ReportsTrendsPage() {
)}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div>
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
@ -123,7 +130,7 @@ export default function ReportsTrendsPage() {
chartType={chartType}
/>
) : (
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
<CategoryOverTimeTable data={categoryOverTime} />
)}
</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 { inPlaceholders } from "../utils/sqlFilters";
import type {
Category,
BudgetEntry,
@ -181,34 +182,49 @@ export async function deleteTemplate(templateId: number): Promise<void> {
// --- Actuals helpers ---
export async function getActualTotalsForYear(
year: number
year: number,
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> {
const dateFrom = `${year}-01-01`;
const dateTo = `${year}-12-31`;
return getActualsByCategoryRange(dateFrom, dateTo);
return getActualsByCategoryRange(dateFrom, dateTo, accountIds);
}
// --- Budget vs Actual ---
async function getActualsByCategoryRange(
dateFrom: string,
dateTo: string
dateTo: string,
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> {
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 }>>(
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
FROM transactions
WHERE date BETWEEN $1 AND $2
WHERE date BETWEEN $1 AND $2${accountFilter}
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(
year: number,
month: number
month: number,
accountIds?: number[]
): Promise<BudgetVsActualRow[]> {
// Date ranges
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
@ -219,8 +235,8 @@ export async function getBudgetVsActualData(
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
getAllActiveCategories(),
getBudgetEntriesForYear(year),
getActualsByCategoryRange(monthFrom, monthTo),
getActualsByCategoryRange(ytdFrom, ytdTo),
getActualsByCategoryRange(monthFrom, monthTo, accountIds),
getActualsByCategoryRange(ytdFrom, ytdTo, accountIds),
]);
// 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 { inPlaceholders } from "../utils/sqlFilters";
import { deriveLandingState } from "../components/balance/balanceLanding";
import type { SnapshotTotalPoint } from "./balance.service";
import type {
DashboardSummary,
CategoryBreakdownItem,
@ -54,7 +57,7 @@ export async function getDashboardSummary(
export async function getExpensesByCategory(
dateFrom?: string,
dateTo?: string,
sourceId?: number,
accountIds?: number[],
): Promise<CategoryBreakdownItem[]> {
const db = await getDb();
@ -72,10 +75,11 @@ export async function getExpensesByCategory(
params.push(dateTo);
paramIndex++;
}
if (sourceId != null) {
whereClauses.push(`t.source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
if (accountPlaceholders) {
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
params.push(...accountIds!);
paramIndex += accountIds!.length;
}
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
@ -162,3 +166,47 @@ export async function getRecentTransactions(
[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

@ -235,6 +235,33 @@ describe("getCartesSnapshot", () => {
expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
});
it("excludes income from top movers so a salary rise is not a red 'increase' (#253)", async () => {
// Post-#253 COMPARE_DELTA_SQL surfaces income as a signed positive SUM, so
// revenue rows now reach getCartesSnapshot. Top movers is a spending view
// (up = red), so income must be filtered out — otherwise a salary jump tops
// "biggest increases" in red. The category metadata query types id 99 as
// income; the two expense movers are absent from it and default to expense.
const momRows = [
{ category_id: 99, category_name: "Salaire", category_color: "#000", month_current_total: 5000, month_previous_total: 100, cumulative_current_total: 5000, cumulative_previous_total: 100 },
{ category_id: 1, category_name: "Épicerie", category_color: "#000", month_current_total: 300, month_previous_total: 100, cumulative_current_total: 300, cumulative_previous_total: 100 },
{ category_id: 2, category_name: "Resto", category_color: "#000", month_current_total: 100, month_previous_total: 400, cumulative_current_total: 100, cumulative_previous_total: 400 },
];
routeSelect([
{ match: "strftime('%Y-%m', date)", rows: [{ month: "2026-03", income: 5000, expenses: 400 }] },
{ match: "ORDER BY ABS(month_current_total - month_previous_total) DESC", rows: momRows },
{ match: "parent_id FROM categories", rows: [{ id: 99, name: "Salaire", color: null, type: "income", parent_id: null }] },
]);
const snapshot = await getCartesSnapshot(2026, 3);
const upNames = snapshot.topMoversUp.map((m) => m.categoryName);
// Income excluded despite the biggest delta (+4900).
expect(upNames).not.toContain("Salaire");
// The biggest EXPENSE increase leads instead.
expect(snapshot.topMoversUp[0].categoryName).toBe("Épicerie");
// Expense decrease still surfaces.
expect(snapshot.topMoversDown[0].categoryName).toBe("Resto");
});
it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
routeSelect([
@ -442,4 +469,77 @@ describe("getCartesSnapshot", () => {
expect(worst.overrunAbs).toBe(150);
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 {
getMonthlyTrends,
getCategoryOverTime,
getHighlights,
getCompareMonthOverMonth,
getCompareYearOverYear,
getCategoryZoom,
buildCompareTree,
buildOverTimeTree,
} from "./reportService";
import type { CategoryDelta } from "../shared/types";
import type { CategoryDelta, OverTimeRow } from "../shared/types";
// Mock the db module
vi.mock("./db", () => {
@ -28,6 +30,66 @@ beforeEach(() => {
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", () => {
it("builds query without WHERE clause when no filters are provided", async () => {
// First call: top categories, second call: monthly breakdown
@ -86,74 +148,171 @@ describe("getCategoryOverTime", () => {
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
});
it("applies sourceId filter", async () => {
it("applies accountIds filter (single id)", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime(undefined, undefined, 50, 3);
await getCategoryOverTime(undefined, undefined, 50, [3]);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
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);
});
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
.mockResolvedValueOnce([]) // topCategories
.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 topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
expect(topCatSQL).toContain("t.date >= $2");
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]);
});
it("returns correct structure with categories, data, colors, and categoryIds", async () => {
it("projects category type into the top-N SELECT", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime();
const topCatSQL = mockSelect.mock.calls[0][0] as string;
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') AS category_type");
});
it("returns correct structure with categories, data, colors, categoryIds, and types", async () => {
mockSelect
.mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
{ category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 },
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
{ category_id: 2, category_name: "Salary", category_color: "#00ff00", category_type: "income", total: 200 },
])
.mockResolvedValueOnce([
{ 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: "Salary", total: 100 },
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
{ month: "2025-02", category_id: 2, category_name: "Transport", total: 100 },
{ month: "2025-02", category_id: 2, category_name: "Salary", total: 100 },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
expect(result.categories).toEqual(["Food", "Transport"]);
expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" });
expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 });
expect(result.categories).toEqual(["Food", "Salary"]);
expect(result.colors).toEqual({ Food: "#ff0000", Salary: "#00ff00" });
expect(result.categoryIds).toEqual({ Food: 1, Salary: 2 });
expect(result.types).toEqual({ Food: "expense", Salary: "income" });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 });
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 100 });
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Salary: 100 });
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Salary: 100 });
});
it("groups non-top-N categories into Other", async () => {
it("defaults a null category type to expense in the types map", async () => {
mockSelect
.mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
// COALESCE(c.type, 'expense') yields 'expense'; a null slipping through defaults too.
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: null, total: 500 },
])
.mockResolvedValueOnce([{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }]);
const result = await getCategoryOverTime();
expect(result.types).toEqual({ Food: "expense" });
});
it("keeps the pivot top-N + Other but the id-keyed tree carries every category (Issue #264)", async () => {
mockSelect
// 1. top categories — top-N = 1, so only Food.
.mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
])
// 2. monthly breakdown — every category (no LIMIT here).
.mockResolvedValueOnce([
{ 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: 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");
// Pivot path is UNCHANGED — top-N (Food) plus an "Other" bucket for the rest.
expect(result.categories).toEqual(["Food", "Other"]);
expect(result.colors["Other"]).toBe("#9ca3af");
expect(result.types).toEqual({ Food: "expense" });
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);
});
});
@ -422,6 +581,46 @@ describe("getCompareMonthOverMonth", () => {
expect(result[0].deltaAbs).toBe(50);
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", () => {
@ -479,6 +678,34 @@ describe("getCompareYearOverYear", () => {
expect(result[0].deltaPct).toBeCloseTo(50, 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)", () => {
@ -487,8 +714,9 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
// compare. Ordinary categories keep the expense-report behavior (only
// outflows, summed as positive magnitudes). The netting lives entirely in
// the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`,
// ABS(t.amount) filtered on amount < 0 for everything else, with the WHERE
// broadened so transfer credits are not dropped before they can cancel.
// ABS(t.amount) filtered on amount < 0 for plain expenses. Income, like
// transfers, is a signed SUM (Issue #253); the WHERE is broadened so
// income/transfer credits are not dropped before they can count or cancel.
//
// There is no runnable SQLite in these unit tests (tauri-plugin-sql only
// exists inside the Tauri WebView, and CI runs Node 20 → no node:sqlite), so
@ -499,11 +727,12 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
/** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */
function bucketContribution(amount: number, type: string | null): number {
return (type ?? "expense") === "transfer"
? amount // signed → a debit and its matching credit cancel
const t = type ?? "expense";
return t === "transfer" || t === "income"
? amount // signed → transfer debit/credit cancel; income credits kept as revenue (#253)
: amount < 0
? Math.abs(amount) // expense outflow as a positive magnitude
: 0; // non-transfer credits dropped → no revenues surfaced
: 0; // other (expense) credits dropped — the report stays outflow-only for them
}
it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => {
@ -518,6 +747,11 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
// Uncategorized (c.type NULL) defaults to expense behavior.
const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0);
expect(uncategorized).toBe(40);
// Income (paie + a small correction) is a signed SUM — credits are kept as
// revenue (Issue #253), not dropped like non-transfer expense credits.
const income = [4200, 150, -50].reduce((s, a) => s + bucketContribution(a, "income"), 0);
expect(income).toBe(4300);
});
it("getCompareMonthOverMonth SQL nets transfers (signed) but keeps ABS for other types", async () => {
@ -528,11 +762,11 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
const sql = mockSelect.mock.calls[0][0] as string;
// transfer → signed t.amount; every other type → ABS(t.amount)
expect(sql).toContain(
"COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)",
"COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
);
// WHERE broadened so transfer credits survive the expense (amount < 0) filter
expect(sql).toContain(
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')",
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
);
// still four date buckets
expect(sql).toContain("month_current_total");
@ -546,10 +780,10 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain(
"COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)",
"COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
);
expect(sql).toContain(
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')",
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
);
});
@ -678,6 +912,8 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
{ id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
] as Parameters<typeof buildCompareTree>[1];
it("nests leaves under a parent subtotal equal to the sum of its children", () => {
@ -723,14 +959,14 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
expect(parent.deltaAbs).toBe(0);
});
it("orders sections expense → income → transfer and keeps subtrees contiguous", () => {
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
const rows = buildCompareTree(
[leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
[leaf(80, "Paie", 4200, 4000), leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
cats,
);
const types = rows.map((r) => r.category_type);
// All expense rows precede all transfer rows.
expect(types).toEqual(["expense", "expense", "transfer", "transfer"]);
// Income section first, then expenses, then transfers (Issue #253).
expect(types).toEqual(["income", "income", "expense", "expense", "transfer", "transfer"]);
});
it("preserves grand-total invariance vs the flat leaves", () => {
@ -765,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)", () => {
it("returns parent subtotal rows when category metadata is available", async () => {
mockSelect

View file

@ -1,10 +1,12 @@
import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService";
import { inPlaceholders } from "../utils/sqlFilters";
import type {
MonthlyTrendItem,
CategoryBreakdownItem,
CategoryOverTimeData,
CategoryOverTimeItem,
OverTimeRow,
HighlightsData,
HighlightMover,
CategoryDelta,
@ -28,7 +30,7 @@ import type {
export async function getMonthlyTrends(
dateFrom?: string,
dateTo?: string,
sourceId?: number,
accountIds?: number[],
): Promise<MonthlyTrendItem[]> {
const db = await getDb();
@ -46,10 +48,11 @@ export async function getMonthlyTrends(
params.push(dateTo);
paramIndex++;
}
if (sourceId != null) {
whereClauses.push(`source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
if (accountPlaceholders) {
whereClauses.push(`source_id IN (${accountPlaceholders})`);
params.push(...accountIds!);
paramIndex += accountIds!.length;
}
const whereSQL =
@ -72,7 +75,7 @@ export async function getCategoryOverTime(
dateFrom?: string,
dateTo?: string,
topN: number = 50,
sourceId?: number,
accountIds?: number[],
typeFilter?: "expense" | "income" | "transfer",
): Promise<CategoryOverTimeData> {
const db = await getDb();
@ -97,20 +100,24 @@ export async function getCategoryOverTime(
params.push(dateTo);
paramIndex++;
}
if (sourceId != null) {
whereClauses.push(`t.source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
if (accountPlaceholders) {
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
params.push(...accountIds!);
paramIndex += accountIds!.length;
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
// Get top N categories by total spend
const topCategories = await db.select<CategoryBreakdownItem[]>(
const topCategories = await db.select<
(CategoryBreakdownItem & { category_type: "expense" | "income" | "transfer" })[]
>(
`SELECT
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(c.type, 'expense') AS category_type,
ABS(SUM(t.amount)) AS total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
@ -124,9 +131,11 @@ export async function getCategoryOverTime(
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
const colors: Record<string, string> = {};
const categoryIds: Record<string, number | null> = {};
const types: Record<string, "expense" | "income" | "transfer"> = {};
for (const cat of topCategories) {
colors[cat.category_name] = cat.category_color;
categoryIds[cat.category_name] = cat.category_id;
types[cat.category_name] = cat.category_type ?? "expense";
}
// Get monthly breakdown for all categories
@ -178,11 +187,58 @@ export async function getCategoryOverTime(
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 {
categories,
data: Array.from(monthMap.values()),
data,
colors,
categoryIds,
types,
tree,
};
}
@ -429,69 +485,118 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
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
* `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 COMPARE_DELTA_SQL (no regression on the leaves shown).
* Minimal category metadata every hierarchical report needs: id, display
* name/color, income-statement `type`, and `parent_id` to walk the hierarchy.
* Shared by the compare tree (Issue #247) and the trends tree (Issue #264).
*/
interface CompareCatMeta {
export interface TreeCatMeta {
id: number;
name: string;
color: string | null;
type: "expense" | "income" | "transfer" | null;
type: TreeSectionType | null;
parent_id: number | null;
}
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
const COMPARE_TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
const MAX_TREE_DEPTH = 5;
/**
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL 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. 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.
* Injection points that specialise the generic tree builder for a concrete row
* type `T`. `T` is BOTH the flat leaf payload fed in and the enriched row shape
* emitted leaf/subtotal rows just add the hierarchy fields (`parent_id`,
* `is_parent`, `depth`, ) the caller writes in `makeLeaf` / `makeSubtotal`.
*/
export function buildCompareTree(
leaves: CategoryDelta[],
categories: CompareCatMeta[],
): CategoryDelta[] {
const catById = new Map<number, CompareCatMeta>();
export interface LeafDrivenTreeOptions<T> {
/** Category hierarchy (fetched without an is_active filter — keeps history). */
categories: TreeCatMeta[];
/** Category a leaf belongs to; a null/absent id → orphan (top-level leaf). */
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);
// Flat delta lookup by category id. Rows whose category id is null
// (Uncategorized) or absent from the table (hard-deleted) become orphans:
// depth-0 leaves preserved in their incoming order.
const deltaByCat = new Map<number, CategoryDelta>();
const orphans: CategoryDelta[] = [];
for (const d of leaves) {
if (d.categoryId != null && catById.has(d.categoryId)) {
deltaByCat.set(d.categoryId, d);
// Flat leaf lookup by category id. Rows whose id is null (Uncategorized) or
// absent from the table (hard-deleted) become orphans: depth-0 leaves
// preserved in their incoming order.
const leafByCat = new Map<number, T>();
const orphans: T[] = [];
for (const l of leaves) {
const id = categoryIdOf(l);
if (id != null && catById.has(id)) {
leafByCat.set(id, l);
} 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>();
for (const id of deltaByCat.keys()) {
for (const id of leafByCat.keys()) {
let cur: number | null | undefined = id;
let guard = 0;
while (cur != null && guard <= MAX_TREE_DEPTH) {
while (cur != null && guard <= maxDepth) {
if (relevant.has(cur)) break;
relevant.add(cur);
cur = catById.get(cur)?.parent_id ?? null;
@ -500,7 +605,7 @@ export function buildCompareTree(
}
// 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) {
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
let arr = childrenByParent.get(c.parent_id);
@ -509,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 = (
cat: CompareCatMeta,
parentId: number | null,
depth: number,
): CategoryDelta => ({
...deltaByCat.get(cat.id)!,
// Builds a node's block: a pure leaf, or a subtotal followed by its
// (recursively built) child blocks, siblings ordered by magnitude desc.
const buildNode = (cat: TreeCatMeta, depth: number): Block | null => {
// Stop descending past the depth cap so a corrupted parent_id cycle can
// never recurse forever — deeper nodes collapse to leaves (real category
// 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,
is_parent: false,
depth,
category_type: typeOf(cat),
});
const subtotalRow = (
cat: CompareCatMeta,
descendantLeaves: CategoryDelta[],
parentId: number | null,
depth: number,
): CategoryDelta => {
}),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
let previousAmount = 0;
let currentAmount = 0;
let cumulativePreviousAmount = 0;
@ -563,80 +769,96 @@ export function buildCompareTree(
depth,
category_type: typeOf(cat),
};
};
interface Block {
rows: CategoryDelta[];
sortKey: number; // |monthly delta| of the block head — orders siblings
}
// Builds a node's block: a pure leaf, or a subtotal followed by its
// (recursively built) child blocks, siblings ordered by |monthly delta| desc.
const buildNode = (cat: CompareCatMeta, depth: number): Block | null => {
// Stop descending past the depth cap so a corrupted parent_id cycle can
// never recurse forever — deeper nodes collapse to leaves (real category
// trees are ≤ 3 levels).
const children = depth >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []);
const hasDirect = deltaByCat.has(cat.id);
if (children.length === 0) {
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),
},
// A parent with both children and its own transactions surfaces the direct
// spend as a "(direct)" leaf (mirrors getBudgetVsActualData).
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
// Orphans keep their own already-computed delta; only the hierarchy fields
// are stamped. Uncategorized rows (category_type undefined) → 'expense'.
makeOrphan: (d) => ({
...d,
parent_id: null,
is_parent: false,
depth: 0,
category_type: d.category_type ?? "expense",
}),
sortKey: (row) => Math.abs(row.deltaAbs),
isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense",
sectionOrder: COMPARE_TYPE_ORDER,
});
}
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) => !r.is_parent);
const subtotal = subtotalRow(cat, descendantLeaves, cat.parent_id ?? null, depth);
return { rows: [subtotal, ...childRows], sortKey: Math.abs(subtotal.deltaAbs) };
// --- Trends hierarchy (Issue #264) ---
/**
* 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.
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 d of orphans) {
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" });
}
// Stable sort by type so sections (expense → income → 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)!;
},
// A parent with both children and its own transactions surfaces the direct
// spend as a "(direct)" leaf (mirrors buildCompareTree / getBudgetVsActualData).
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
// Orphans (Uncategorized / hard-deleted) keep their own series; only the
// hierarchy fields are stamped. No metadata → default 'expense'.
makeOrphan: (l) => ({
...l,
parent_id: null,
is_parent: false,
depth: 0,
category_type: l.category_type ?? "expense",
}),
sortKey: (row) => Math.abs(row.total),
isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense",
sectionOrder: COMPARE_TYPE_ORDER,
});
return rows;
}
function previousMonth(year: number, month: number): { year: number; month: number } {
@ -649,52 +871,63 @@ function previousMonth(year: number, month: number): { year: number; month: numb
* exact same query only the eight date bounds differ). Four date buckets
* ($1..$8): monthly current/previous and cumulative current/previous.
*
* Per-category sign convention (Issue #243):
* - `transfer` categories NET via a signed SUM(t.amount), so a balanced
* debit/credit pair (e.g. "Paiement CC": -500 out, +500 in) cancels to ~0.
* A transfer is a money move, not spending, and must not inflate the
* expense figures.
* This report is an INCOME STATEMENT ("analyse de résultat", Issue #253), not a
* pure expense report: revenues are surfaced alongside expenses so the compare
* can show a result (revenues expenses). Per-category sign convention:
* - `income` and `transfer` categories use a signed SUM(t.amount). Income
* credits stay positive; a `transfer` debit/credit pair (e.g. "Paiement CC":
* -500 out, +500 in) nets to ~0 (Issue #243 a money move, not spending).
* - Every other type keeps the expense-report behavior: only outflows
* (amount < 0) count, summed as positive magnitudes via ABS.
*
* The WHERE is broadened with `OR COALESCE(c.type, 'expense') = 'transfer'` so
* transfer *credits* survive the `amount < 0` expense filter and can actually
* cancel their debits without it the credit leg would be dropped and the
* category could never net to zero. For non-transfer rows the WHERE still
* admits only outflows, so their totals are byte-identical to the previous
* behavior (no revenues surfaced, no zero-rows for pure-income categories).
* Uncategorized rows (LEFT JOIN c.type NULL) default to 'expense'.
* The WHERE admits outflows OR any income/transfer row, so income and transfer
* *credits* survive the `amount < 0` filter without it a pure-income category
* would be dropped entirely and a transfer could never net to zero. Expense
* rows still contribute only their outflows, byte-identical to before. The two
* result lines (before/after transfers) are computed in ComparePeriodTable from
* 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,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')
WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))
AND (
(t.date >= $1 AND t.date <= $2)
OR (t.date >= $3 AND t.date <= $4)
OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8)
)
)${accountFilter}
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`;
}
/**
* Month-over-month expense delta by category. Returns both a monthly view
* (reference month vs immediately-previous month) and a cumulative YTD view
* (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative
* 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(
year: number,
month: number,
accountIds?: number[],
): Promise<CategoryDelta[]> {
const db = await getDb();
const { start: curStart, end: curEnd } = monthBoundaries(year, month);
@ -712,16 +945,19 @@ export async function getCompareMonthOverMonth(
const cumPreviousStart = `${prev.year}-01-01`;
const cumPreviousEnd = prevEnd;
// 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[]>(COMPARE_DELTA_SQL, [
const deltaParams: unknown[] = [
curStart, curEnd,
prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd,
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),
]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
@ -733,11 +969,13 @@ export async function getCompareMonthOverMonth(
* view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the
* reference year's December as the "current month" when no explicit
* 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(
year: number,
month: number = 12,
accountIds?: number[],
): Promise<CategoryDelta[]> {
const db = await getDb();
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
@ -748,14 +986,17 @@ export async function getCompareYearOverYear(
const cumPreviousStart = `${year - 1}-01-01`;
const cumPreviousEnd = prevMonthEnd;
// See getCompareMonthOverMonth: delta select first, categories alongside.
const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
const deltaParams: unknown[] = [
curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd,
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),
]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
@ -945,18 +1186,25 @@ interface RawMonthFlow {
async function fetchMonthlyFlows(
dateFrom: string,
dateTo: string,
accountIds?: number[],
): Promise<RawMonthFlow[]> {
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[]>(
`SELECT
strftime('%Y-%m', date) AS month,
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
FROM transactions
WHERE date >= $1 AND date <= $2
WHERE date >= $1 AND date <= $2${accountClause}
GROUP BY month
ORDER BY month ASC`,
[dateFrom, dateTo],
params,
);
}
@ -969,9 +1217,16 @@ async function fetchSeasonality(
month: number,
yearFrom: number,
yearTo: number,
accountIds?: number[],
): Promise<RawSeasonalityRow[]> {
const db = await getDb();
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[]>(
`SELECT
CAST(strftime('%Y', date) AS INTEGER) AS year,
@ -979,10 +1234,10 @@ async function fetchSeasonality(
FROM transactions
WHERE strftime('%m', date) = $1
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
ORDER BY year DESC`,
[mm, yearFrom, yearTo],
params,
);
}
@ -998,11 +1253,19 @@ async function fetchSeasonality(
* via the monthly series instead of re-querying.
* 4. Budget vs actual for the reference month.
* 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(
referenceYear: number,
referenceMonth: number,
mode: CartesKpiPeriodMode = "month",
accountIds?: number[],
): Promise<CartesSnapshot> {
// 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.
@ -1012,10 +1275,10 @@ export async function getCartesSnapshot(
// Seasonality range: previous 2 years for the same calendar month.
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
fetchMonthlyFlows(windowStartIso, refEnd),
getCompareMonthOverMonth(referenceYear, referenceMonth),
getBudgetVsActualData(referenceYear, referenceMonth),
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1, accountIds),
fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
]);
// Index the flow rows by month for O(1) lookup, then fill missing months
@ -1168,12 +1431,20 @@ export async function getCartesSnapshot(
// Top movers: biggest MoM increases / decreases. `momRows` now carries the
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
// parent group can't double-count against its own leaves. The surviving leaves
// are byte-identical to the previous flat output; the sort/slice below is
// unchanged. `momRows` are sorted by absolute delta already; filter out
// near-zero noise and split by sign.
// parent group can't double-count against its own leaves. Keep only EXPENSE
// leaves: since Issue #253 broadened compareDeltaSql() to surface income (and
// 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
// salary rise must not appear under "biggest increases" in red. Mirror the
// expense-only filter ComparePeriodChart uses. The surviving expense leaves
// are byte-identical to the pre-#253 flat output. `momRows` are sorted by
// absolute delta already; filter out near-zero noise and split by sign.
const significantMovers = momRows.filter(
(r) => !r.is_parent && r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0),
(r) =>
!r.is_parent &&
(r.category_type ?? "expense") === "expense" &&
r.deltaAbs !== 0 &&
(r.previousAmount > 0 || r.currentAmount > 0),
);
// Project the richer CategoryDelta shape down to the narrower CartesTopMover
// shape so the Cartes dashboard keeps its stable contract regardless of how

View file

@ -285,6 +285,17 @@ export interface RecentTransaction {
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 {
categoryId: number | null;
categoryName: string;
@ -367,11 +378,45 @@ export interface CategoryOverTimeItem {
[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 {
categories: string[];
data: CategoryOverTimeItem[];
colors: Record<string, string>;
categoryIds: Record<string, number | null>;
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
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 {

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(", ");
}