Compare commits

...

66 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
le king fu
206f89e1b1 chore: release v0.11.0
All checks were successful
Release / build-and-release (push) Successful in 24m28s
2026-07-05 17:10:00 -04:00
le king fu
9124c888de Merge PR #252: feat(categories) editable migration target with type-ahead (#246) 2026-07-05 17:04:15 -04:00
le king fu
2a3cc07a0e Merge PR #251: feat(balance) CSV import of holdings in detailed snapshot (#245) 2026-07-05 17:03:00 -04:00
le king fu
ead62943fe Merge PR #250: feat(balance) tile-based landing hub + reachable account management (#244) 2026-07-05 17:01:20 -04:00
le king fu
8bd51214c1 Merge PR #249: feat(reports) hierarchical real-vs-real compare with subtotals (#247) 2026-07-05 16:59:17 -04:00
le king fu
a6fa89e041 Merge PR #248: fix(reports) net transfer-type categories in real-vs-real compare (#243) 2026-07-05 16:59:17 -04:00
le king fu
4f87ce329c polish(categories): honest doc + extract/test picker-options glue (#246 review)
All checks were successful
PR Check / rust (pull_request) Successful in 22m33s
PR Check / frontend (pull_request) Successful in 2m26s
Address the two non-blocking review notes on PR #252:
- The re-injected non-leaf parent (e.g. #1710) is technically clickable, not
  'never offered'; correct the MappingRow comment to state selecting it is a
  no-op (only leaves resolve).
- Extract the per-row option-building glue into a pure, unit-tested helper
  comboboxCategoriesForTarget (4 cases: null/leaf pass-through by reference,
  non-leaf append, unresolvable stale id).
2026-07-05 16:53:27 -04:00
le king fu
ce2793fc2c refactor(categories): migration target picker lists leaves only
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m30s
The v1 target combobox in the migration preview now offers only leaf
categories (the inputable end-categories), not intermediate parents, so a
transaction is never mapped to a grouping bucket. Leaves render as a flat,
un-indented list in taxonomy order.

A low-confidence default can still point a v2 category at a non-leaf parent
(e.g. 'Divertissement' #1710); MappingRow re-injects that current target for
its single row via findTaxonomyCategory so the input stays populated, while
new picks remain leaves-only. Adds a findTaxonomyCategory pure helper + tests
(adapter now returns 112 leaves, not the 150-node full tree).
2026-07-04 20:40:29 -04:00
le king fu
e45736bbde feat(categories): editable migration target with type-ahead (#246)
All checks were successful
PR Check / rust (pull_request) Successful in 22m32s
PR Check / frontend (pull_request) Successful in 2m28s
Make the v1 target editable on EVERY row of the categories-migration
preview (StepSimulate), not only the "needs review" ones. Each row now
renders a reused CategoryCombobox (accent-insensitive type-ahead,
keyboard, hierarchical) in place of the read-only text / flat <select>.

A pure adapter (taxonomyToComboboxCategories) flattens the full v1
taxonomy into Category-shaped rows once in StepSimulate and passes it as
a prop to the rows. The full tree (not just leaves) is fed so mid-tree
default targets such as "Divertissement" (id 1710, the default for
"Jeux, Films & Livres") both display and can be re-picked. The target
cell stops click/keydown propagation so picker interaction never toggles
the row preview. Reducer/guard behavior unchanged: resolving a "none"
row still bumps confidence to medium, and the unresolved-count guard
stays functional.

Resolves #246
2026-07-04 18:17:10 -04:00
le king fu
c8c765e2f0 feat(balance): CSV import of holdings in detailed snapshot
All checks were successful
PR Check / rust (pull_request) Successful in 22m45s
PR Check / frontend (pull_request) Successful in 2m25s
A detailed (by-security) account can now import its positions from a CSV
instead of adding each security one by one. An "Import CSV" button next to
"Add a title" opens a native picker; the delimiter, encoding and columns
(symbol, quantity, optional price + book_cost) are auto-detected via new
csvAutoDetect helpers and adjustable in a small mapping editor. Rows sharing
a symbol are merged (SUM qty + book_cost, first price) and the batch is merged
into the basket by normalized symbol, so no UNIQUE(snapshot_line_id,
security_id) violation can occur at save. A CSV without a price column imports
quantities with an empty unit_price (fetch/type later) — the existing atomic
save path is unchanged (no SQL/Rust change).

- csvAutoDetect: autoDetectHoldingColumns + analyzeHoldingsCsv (+ tests)
- useSnapshotEditor: holdingsFromCsvRows + IMPORT_HOLDINGS action + importHoldings (+ tests)
- HoldingsCsvImportModal: file pick -> parse -> mapping editor + preview
- i18n FR/EN under balance.snapshot.detailed.importCsv.*
- CHANGELOG (Added / Ajouté)

Resolves #245

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:04:12 -04:00
le king fu
82550d6d38 feat(balance): tile-based landing hub + reachable account management
All checks were successful
PR Check / rust (pull_request) Successful in 25m34s
PR Check / frontend (pull_request) Successful in 2m30s
Decouple the /balance empty-state guard into three explicit states via a
new pure helper `deriveLandingState` (empty / accounts-no-snapshot /
populated). Having accounts but no snapshot no longer strands the user on a
snapshot-only onboarding card: each state now renders navigation tiles
(HubReportNavCard, modeled on the Reports hub), and "Manage accounts"
(/balance/accounts) is reachable in every state, including the populated
dashboard.

- Remove BalanceOnboardingCard (component + test) and the dead
  balance.onboarding.* i18n keys; add balanceLanding.ts + test.
- Add balance.hub.* and balance.landing.* keys in FR and EN.
- CHANGELOG (EN + FR) under [Unreleased].

Resolves #244
2026-07-04 17:43:43 -04:00
le king fu
f3e8e94b16 feat(reports): hierarchical real-vs-real compare with subtotals
Render the real-vs-real comparable report as a parent/child category tree
with subtotal rows, mirroring the budget report — rows grouped into
expense/income/transfer sections with per-section and grand totals and a
subtotals-on-top/bottom toggle. The compare service now builds the tree on
top of the flat per-category deltas, so leaf-category figures are unchanged
and the #243 transfer netting is preserved (a balanced transfer group
subtotals to ~0, i.e. the group's net).

- reportService: buildCompareTree() synthesizes subtotal rows from the flat
  leaves + category metadata; getCompareMonthOverMonth/YoY return the tree
  (COMPARE_DELTA_SQL and rowsToDeltas untouched).
- CategoryDelta gains optional parent_id/is_parent/depth/category_type.
- ComparePeriodTable: sections, depth indentation, reorderRows toggle,
  section/grand net totals.
- Cartes top-movers and ComparePeriodChart filter to leaf rows only.
- reorderRows constraint loosened to the two fields it reads.
- i18n (FR+EN) section labels; CHANGELOG entries.

Resolves #247

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:32:38 -04:00
le king fu
4b000d022f fix(reports): net transfer-type categories in real-vs-real compare
All checks were successful
PR Check / rust (pull_request) Successful in 23m23s
PR Check / frontend (pull_request) Successful in 2m25s
Reports > Compare (real vs real) summed only outflows (SUM(ABS(amount))
filtered on amount < 0), so a `transfer` category like "Paiement CC" -
whose debit and matching credit should cancel - always showed the sum of
its debits instead of ~0.

Net transfer-type categories via a signed SUM(t.amount) while keeping the
expense behavior (ABS + amount < 0) for every other type, and broaden the
WHERE so transfer credits survive the expense filter. Both MoM and YoY now
share a single COMPARE_DELTA_SQL constant so they stay in sync. Real-vs-
budget already nets (signed SUM, no filter) and is unchanged.

Resolves #243

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:08:17 -04:00
le king fu
110ad57e1b state: sync after #241 (@babel/core override merged, npm audit 0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 16:15:03 -04:00
9e31f0ee5b Merge pull request 'fix(deps): scope @babel/core to 7.29.7 via override (security #241)' (#242) from issue-241-babel-override into main 2026-07-04 20:12:27 +00:00
le king fu
54ef0f0229 fix(deps): scope @babel/core to 7.29.7 via override (fixes #241)
All checks were successful
PR Check / rust (pull_request) Successful in 23m2s
PR Check / frontend (pull_request) Successful in 2m30s
@babel/core@7.29.0 (pulled transitively by @vitejs/plugin-react, build-only)
was flagged by GHSA-4x5r-pxfx-6jf8 (arbitrary file read via sourceMappingURL,
low, CVSS 3.2). npm audit fix cannot resolve it — the version is pinned by the
parent — so a scoped overrides entry forces the patched 7.29.7 within the
parent's accepted 7.x range (non-breaking). npm audit now reports 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:43:28 -04:00
le king fu
4dc3baaa9b state: sync after deps-security-2026-07 sprint (4/4) + changelog security entry
Milestone deps-security-2026-07 shipped 4/4 (closed): PR #239 (react-router-dom
7.18.1, fixes #235+#238) + PR #240 (vite 6.4.3 + vitest 4.1.9, fixes #236+#237).
npm audit 5 -> 1 (only @babel/core low remains, out of scope). Added [Unreleased]
Security entries in both changelogs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:22:45 -04:00
9e9ffa088a Merge pull request 'fix(deps): bump vite to 6.4.3 + vitest to 4.1.9 (security #236, #237)' (#240) from issue-237-vite-vitest into main 2026-07-02 00:18:30 +00:00
le king fu
23b574ebcb fix(deps): bump vite to 6.4.3 and vitest to 4.1.9 (fixes #236, #237)
All checks were successful
PR Check / rust (pull_request) Successful in 28m3s
PR Check / frontend (pull_request) Successful in 2m39s
vite@6.4.2: server.fs.deny bypass (GHSA-fx2h-pf6j-xcff, high) + NTLMv2 hash
disclosure (GHSA-v6wh-96g9-6wx3, moderate) -- Windows-only, dev server.
vitest@4.0.18: UI server arbitrary file read/exec (GHSA-5xrq-8626-4rwp,
critical) -- only when 'vitest --ui' is listening. Both dev-only. Bumped
within 6.x/4.x (no major). 635 tests pass, build green.

npm audit: 3 -> 1 (only @babel/core low remains; left out deliberately --
audit fix adds 77 packages without resolving it, needs a scoped override
in a separate change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:01:45 -04:00
6aa4f66242 Merge pull request 'fix(deps): bump react-router-dom to 7.18.1 (security #235, #238)' (#239) from issue-238-react-router-dom into main 2026-07-01 23:58:53 +00:00
le king fu
713667c33d fix(deps): bump react-router-dom to 7.18.1 (fixes #235, #238)
All checks were successful
PR Check / rust (pull_request) Successful in 23m56s
PR Check / frontend (pull_request) Successful in 2m35s
react-router-dom@7.13.0 pulled react-router@7.13.0, affected by 7 advisories
(turbo-stream RCE, DoS __manifest/single-fetch, XSS RSC redirect), corrected
in <7.15.1. Bumping the parent to 7.18.1 pulls react-router@7.18.1 and clears
both npm audit entries. react-router-dom carries no advisory of its own (#238
inherited transitively from #235). Build (tsc + vite) green; no major bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 08:41:51 -04:00
le king fu
746d5ee3ab state: sync after #230 — v0.10.0 + hotfix v0.10.1 shipped (database-locked fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:50:39 -04:00
le king fu
0eb7a3887d chore: release v0.10.1
All checks were successful
Release / build-and-release (push) Successful in 26m19s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:22:13 -04:00
f64be5c31d Merge pull request 'fix(balance): "database is locked" after abandoning a snapshot + live log console' (#230) from fix-database-locked-serialization into main 2026-06-30 21:19:41 +00:00
le king fu
8cb7e53698 fix(db): serialize DB access to fix "database is locked"; live-update log console
All checks were successful
PR Check / rust (pull_request) Successful in 24m30s
PR Check / frontend (pull_request) Successful in 2m38s
Database lock (the reported 0.10.0 regression):
- tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool
  (Pool::connect => max_connections = 10) and exposes no JS transaction
  primitive. BEGIN and COMMIT issued as separate db.execute calls could land on
  different pooled connections, stranding an open write transaction on an idle
  connection — a permanent write lock until app restart. Triggered when the
  Bilan page's concurrent reads interleaved with an in-flight snapshot save.
- Fix: funnel every db operation through one FIFO lock in db.ts; withTransaction
  holds it across the whole BEGIN..COMMIT so a transaction's statements never
  span connections (and the saves become genuinely atomic). A reentrancy guard
  lets nested getDb() calls run directly instead of deadlocking. Applied to all
  5 transaction sites (saveSnapshotAtomic, upsertSnapshotLines,
  proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) via
  in-place helper extraction. New db.test.ts covers serialization,
  cross-transaction non-interleaving, reentrancy, and lock release on error.

Log console (Settings -> Systems -> Journaux):
- getLogs returned the same mutated array reference, so useSyncExternalStore
  (identity comparison) never re-rendered on a new log -> the console never
  updated live. getLogs now returns an immutable snapshot rebuilt on each
  mutation (stable between mutations, new identity after each).
- Add logInfo/logWarn/logError app-logging API and instrument the snapshot save
  (info on success, error on failure) so DB issues surface in the console.

tsc + 635 vitest + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:50:39 -04:00
89 changed files with 7798 additions and 1412 deletions

View file

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

5
.gitignore vendored
View file

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

View file

@ -2,6 +2,65 @@
## [Non publié] ## [Non publié]
## [0.13.0] - 2026-07-12
### Ajouté
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272).
- Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273).
- Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274).
- Rapports → Tendances : le panneau de filtre partagé apparaît désormais sur ce rapport, à côté du sélecteur de période. Cocher une ou plusieurs sources d'import restreint à la fois la vue mensuelle globale et le tableau de résultat par catégorie à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#275).
- Rapports → Comparaison et Budget : le panneau de filtre partagé apparaît désormais aussi sur ces deux rapports, à côté de leur contrôle de période existant (la comparaison garde son sélecteur de mois de référence, la grille budget garde son navigateur d'année — inchangés). Cocher une ou plusieurs sources d'import restreint le rapport de comparaison — y compris son onglet budget-vs-réel — et la colonne de référence de l'année précédente de la grille budget à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#276).
### Corrigé
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat couvre désormais **toutes** les catégories au lieu des 50 plus grosses, et ses lignes de Résultat sont exactes. Deux catégories différentes portant le même nom ne sont plus fusionnées, et un petit revenu ou transfert n'est plus comptabilisé comme une dépense — les revenus et le résultat net s'additionnent donc correctement (#264).
### Modifié
- Rapports → Tendances : le rapport **s'ouvre désormais sur la vue « Par catégorie » en tableau** par défaut, au lieu du graphique mensuel global. Le tableau par catégorie comporte une section revenus, donc une catégorie de revenu (ex. votre paie) apparaît d'emblée — sans changer de vue au préalable. La vue que vous choisissez vous-même reste mémorisée (#262).
- Rapports → Comparaison (réel vs budget) : le tableau budget-vs-réel se lit désormais comme les autres rapports comparables — une **analyse de résultat**. Les catégories sont regroupées Revenus → Dépenses → Transferts (au lieu de Dépenses → Revenus → Transferts), et l'ancienne ligne « Total » plate est remplacée par une ligne **Résultat avant transferts** (affichée lorsque des transferts existent) et une ligne **Résultat net**, toutes deux colorées en vert pour un surplus, en rouge pour un déficit. Les montants de catégorie et de section sont inchangés (#277).
- Budget : la grille budget se lit désormais comme une analyse de résultat — les catégories sont regroupées **Revenus → Dépenses → Transferts** (auparavant dépenses en premier), et l'ancienne ligne « Total » brute est remplacée par deux lignes de résultat : **Résultat avant transferts** (revenus dépenses) et **Résultat net** (après transferts), calculées sur les colonnes année précédente, annuelle et mensuelles. Toutes les catégories restent affichées sur la grille — elle demeure une surface d'édition, sans ligne masquée ni repliable (#278).
- Tableau de bord (`/`) : la page d'accueil adopte désormais le modèle du rapport Cartes. Les cartes d'indicateurs (revenus, dépenses, résultat net, taux d'épargne) affichent leur variation vs le mois précédent et vs l'an dernier (sélecteur de mois de référence, par défaut le dernier mois complet) ; les catégories en hausse/en baisse et le respect du budget sont présentés de la même façon que sur Cartes. Une nouvelle tuile **valeur nette** affiche le dernier total du Bilan — une métrique différente de toutes les autres cartes de la page, donc masquée (jamais un « 0 $ » trompeur) tant qu'aucun compte de bilan n'a de snapshot enregistré, et jamais affectée par le filtre de comptes ci-dessous. Le camembert des dépenses est remplacé par des **barres classées** des catégories de dépense les plus importantes, et le filtre de comptes (sources d'import) — introduit sur Tendances/Comparaison/Budget — s'applique désormais aussi aux widgets transactionnels du tableau de bord (#279).
## [0.12.0] - 2026-07-05
### 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é
- Rapports → Comparaison (réel vs réel) : le rapport comparable s'affiche désormais en **hiérarchie de catégories avec sous-totaux**, comme le rapport de budget. Les catégories parentes regroupent leurs enfants dans une ligne de sous-total — sa valeur est le net du groupe, donc un groupe de transferts équilibrés s'annule à ~0 — avec les lignes réparties en sections Dépenses / Revenus / Transferts portant des totaux par section et un total général, plus un choix sous-totaux en haut/en bas. Les montants des catégories feuilles sont inchangés, et l'annulation des transferts du correctif précédent est préservée (#247).
- Bilan : un compte détaillé (par titre) peut désormais **importer ses positions depuis un CSV** au lieu d'ajouter chaque titre un à un. Depuis un snapshot, un bouton « Importer un CSV » à côté de « Ajouter un titre » ouvre un sélecteur de fichier ; le séparateur, l'encodage et les colonnes (symbole, quantité et — au besoin — cours et coût d'acquisition) sont détectés automatiquement et ajustables avant l'import. Les lignes partageant un symbole sont fusionnées en une seule position, et un CSV sans colonne de cours importe tout de même les quantités pour récupérer ou saisir le prix ensuite (#245).
### Modifié
- Bilan : la page d'accueil du bilan est désormais un hub de tuiles. Avoir des comptes mais aucun snapshot ne vous bloque plus sur une invite « créer un snapshot » — la page affiche maintenant des tuiles de navigation, et **Gérer les comptes** est accessible en tout temps, y compris depuis le tableau de bord peuplé. La page de gestion des comptes (créer/modifier/archiver les comptes et les types d'actif) n'était auparavant accessible qu'en tapant son URL (#244).
- Migration des catégories (Paramètres → Catégories → migrer vers le jeu standard) : la catégorie cible de l'étape d'aperçu est désormais modifiable sur **chaque** ligne, et non plus seulement celles « à réviser ». Chaque ligne offre un champ de recherche avec autocomplétion (insensible aux accents), pour corriger une cible détectée automatiquement aussi facilement que résoudre une ligne sans correspondance (#246).
### Sécurité
- Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238).
- Épinglé la dépendance transitive de build `@babel/core` en 7.29.7 via une entrée `overrides` scopée (sous `@vitejs/plugin-react`), corrigeant le dernier advisory `npm audit` (GHSA-4x5r-pxfx-6jf8, lecture de fichier arbitraire via un commentaire `sourceMappingURL`, sévérité faible). Outillage de build uniquement — aucun changement runtime ni de comportement — et `npm audit` est désormais propre (#241).
### Corrigé
- Rapports → Comparaison (réel vs réel) : les catégories de type transfert (ex. « Paiement CC ») s'annulent maintenant à ~0 au lieu d'afficher seulement leurs sorties. Un transfert est un mouvement d'argent, pas une dépense — son débit et son crédit correspondant sur la même période se compensent. Les catégories de dépense et de revenu conservent exactement leurs montants précédents (#243).
## [0.10.1] - 2026-06-30
### Corrigé
- Bilan : correction d'une erreur **« database is locked »** qui pouvait apparaître après avoir commencé un snapshot puis quitté la page avant d'enregistrer, bloquant ensuite toute écriture jusqu'au redémarrage de l'app. Tout l'accès à la base est désormais sérialisé, et une transaction tient une seule connexion sur toute sa durée — une transaction d'écriture ne peut donc plus rester bloquée entre deux connexions SQLite (la cause racine). Aucune donnée enregistrée n'a été menacée ; seul le snapshot en cours était perdu (#230).
- La console de journaux intégrée (Paramètres → Systèmes → Journaux) se met maintenant à jour **en direct** à mesure que les événements surviennent, au lieu de n'afficher que les entrées présentes avant l'ouverture de la page. Les enregistrements de snapshot et leurs échecs sont désormais journalisés, donc les problèmes de base de données y apparaissent (#230).
## [0.10.0] - 2026-06-29 ## [0.10.0] - 2026-06-29
### Ajouté ### Ajouté

View file

@ -2,6 +2,65 @@
## [Unreleased] ## [Unreleased]
## [0.13.0] - 2026-07-12
### Added
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272).
- Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273).
- Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274).
- Reports → Trends: the shared filter panel now appears on this report, next to the period selector. Checking one or more import sources narrows both the global monthly view and the by-category result table to those sources; leaving none checked keeps showing every source, unchanged (#275).
- Reports → Compare & Budget: the shared filter panel now appears on both reports too, next to their existing period control (the compare report keeps its reference-month picker, the budget grid keeps its year navigator — unchanged). Checking one or more import sources narrows the compare report — including its budget-vs-actual tab — and the budget grid's previous-year reference column to those sources; leaving none checked keeps showing every source, unchanged (#276).
### Fixed
- Reports → Trends → by category (table view): the income-statement table now covers **every** category instead of only the 50 largest, and its Result lines are exact. Two different categories that happen to share a name are no longer merged together, and a smaller income or transfer category is no longer miscounted as an expense — so the revenues and the net result add up correctly (#264).
### Changed
- Reports → Trends: the report now **opens on the "By category" table view** by default, instead of the global monthly chart. The by-category table has an income section, so a revenue category (e.g. your pay) shows up straight away — no need to switch views first. Whatever view you pick yourself is still remembered (#262).
- Reports → Compare (actual vs budget): the budget-vs-actual table now reads the same way as the other comparable reports — an **income statement**. Categories are grouped Income → Expenses → Transfers (instead of Expenses → Income → Transfers), and the old flat "Total" row is replaced by a **Result before transfers** line (shown when transfers exist) and a **Net result** line, both coloured green for a surplus and red for a deficit. Leaf and section figures are unchanged (#277).
- Budget: the budget grid now reads as an income statement — categories are grouped **Income → Expenses → Transfers** (previously expenses first), and the old plain "Total" row is replaced by two result lines: **Result before transfers** (income expenses) and **Net result** (after transfers), computed across the previous-year, annual, and monthly columns alike. Every category still shows on the grid — it stays an editable surface, with no rows hidden or collapsed (#278).
- Dashboard (`/`): the home page now matches the Cartes report's model. KPI cards for income, expenses, net result and savings rate show their change vs the previous month and vs last year (reference-month picker, defaulting to the last complete month); rising/falling categories and budget adherence are shown the same way as on Cartes. A new **net worth** tile shows the Balance sheet's latest total — a different metric from every other card here, so it stays hidden (never a misleading "$0") until at least one balance account has a recorded snapshot, and it is never affected by the account filter below. The expense-only pie chart is replaced by a **ranked bar chart** of top expense categories, and the account (import-source) filter — introduced on Trends/Compare/Budget — now also applies to the Dashboard's own transactional widgets (#279).
## [0.12.0] - 2026-07-05
### 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
- Reports → Compare (real vs real): the comparable report is now shown as a **category hierarchy with subtotals**, like the budget report. Parent categories roll their children up into a subtotal row — its value is the group's net, so a group of balanced transfers subtotals to ~0 — with rows grouped into Expenses / Income / Transfers sections carrying per-section and grand totals, and a subtotals-on-top/bottom toggle. Leaf-category figures are unchanged, and the transfer netting from the previous fix is preserved (#247).
- Balance: a detailed (by-security) account can now **import its positions from a CSV** instead of adding each security one by one. From a snapshot, an "Import CSV" button next to "Add a title" opens a file picker; the delimiter, encoding, and columns (symbol, quantity, and — optionally — price and cost basis) are auto-detected and can be adjusted before importing. Rows that share a symbol are merged into one position, and a CSV without a price column still imports the quantities so the price can be fetched or typed afterwards (#245).
### Changed
- Balance: the balance-sheet landing page is now a tile-based hub. Having accounts but no snapshot yet no longer strands you on a "create a snapshot" prompt — the page now shows navigation tiles, and **Manage accounts** is reachable at all times, including from the populated dashboard. The account-management page (create/edit/archive accounts and asset types) was previously only reachable by typing its URL (#244).
- Categories migration (Settings → Categories → migrate to the standard set): the target category on the preview step is now editable on **every** row, not only the "needs review" ones. Each row carries a type-ahead picker (accent-insensitive search) so you can override an auto-detected target as easily as resolving an unmatched one (#246).
### Security
- Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238).
- Pinned the build-time transitive dependency `@babel/core` to 7.29.7 through a scoped `overrides` entry (under `@vitejs/plugin-react`), clearing the last `npm audit` advisory (GHSA-4x5r-pxfx-6jf8, arbitrary file read via a `sourceMappingURL` comment, low severity). Build tooling only — no runtime or behaviour change — and `npm audit` is now clean (#241).
### Fixed
- Reports → Compare (real vs real): transfer-type categories (e.g. "Paiement CC") now net to ~0 instead of showing only their outflows. A transfer is a money move, not spending — its debit and matching credit over the same period cancel out. Expense and income categories keep exactly their previous amounts (#243).
## [0.10.1] - 2026-06-30
### Fixed
- Balance: fixed a **"database is locked"** error that could appear after starting a snapshot and leaving the page before saving, then blocked every later write until the app was restarted. All database access is now serialized, and a transaction holds a single connection for its whole duration — so a write transaction can no longer be stranded across SQLite connections (the root cause). No stored data was ever at risk; only the in-progress snapshot was lost (#230).
- The in-app log console (Settings → Systems → Logs) now updates **live** as events happen, instead of only showing entries that existed before the page was opened. Snapshot saves and their failures are now logged, so database issues surface there (#230).
## [0.10.0] - 2026-06-29 ## [0.10.0] - 2026-06-29
### Added ### Added

View file

@ -1,25 +1,32 @@
# STATE — Simpl'Résultat # STATE — Simpl'Résultat
> Derniere MAJ : 2026-06-29 (Etape 2 bilan complete — #228 mergé, milestone fermée, prêt pour release 0.10.0) > Derniere MAJ : 2026-07-12 (**#259 recadrée** après `/analyze` : son corps décrivait un flux inexistant du Bilan, le vrai sujet est le **mapping manuel des catégories custom** en migration de taxonomie → réécrite `status:ready`, **hors epic #260** ; l'epic #260 n'a donc plus de reliquat. Précédemment 2026-07-11 : **M2 « rapports-parite » #277-#279 shippée** sur `main` `a982f9e` — /pr-review ×3 [APPROVE #285/#286, REQUEST_CHANGES #287 → point I7 « filtrage Cartes » confirmé + fix `9ee5ad3`], merge local pile de 3 + union CHANGELOG, tip cumulé validé **811 vitest** + 98 Rust, réconciliation Forgejo complète. **30 commits non taggés depuis `v0.12.0`** — release en attente)
## Position actuelle ## Position actuelle
v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-seed-categories-ipc`, `spec-price-fetching`, `overnight-2026-04-26-bilan` et `overnight-2026-04-27-prices` complétées et fermées. Maximus-api 0.3.0 LIVE en prod (`api.lacompagniemaximus.com`) — pubkey Ed25519 alignée, smoke test Phase B (#161) validé. Backlog actif concentré sur `spec-monetisation` (7/12) : activation en ligne (#53), pipeline Stripe desktop (#50, #52) et maximus-api Stripe webhooks (#135, #136). v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-seed-categories-ipc`, `spec-price-fetching`, `overnight-2026-04-26-bilan` et `overnight-2026-04-27-prices` complétées et fermées. Maximus-api 0.3.0 LIVE en prod (`api.lacompagniemaximus.com`) — pubkey Ed25519 alignée, smoke test Phase B (#161) validé. Backlog actif concentré sur `spec-monetisation` (7/12) : activation en ligne (#53), pipeline Stripe desktop (#50, #52) et maximus-api Stripe webhooks (#135, #136).
Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. Prochaine étape : **tag release 0.10.0** (Étapes 1+2 sous [Unreleased] EN+FR, jamais taggées — v0.9.1 s'arrêtait à v13). 20 tables / 24 index, 16 migrations (v1→v16). Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. **v0.10.0 shippée** (2026-06-29 : Étapes 1+2 du bilan, migrations v12→v16) **puis hotfix v0.10.1** (2026-06-30, PR #230, déployé) — corrige un « database is locked » introduit en 0.10.0 (apparaissait après l'abandon d'un snapshot en cours) : tout l'accès DB est désormais sérialisé via `withTransaction` dans `db.ts` (tauri-plugin-sql = pool sqlx multi-connexions sans primitive de transaction JS → `BEGIN`/`COMMIT` en `db.execute` séparés pouvaient strander une transaction d'écriture = verrou zombie), appliqué aux 5 sites transactionnels ; + console de log live-update + API `logInfo/logWarn/logError`. 20 tables / 24 index, 16 migrations (v1→v16).
**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 ## 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-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)
- 2026-05-03 : WebKitGTK date picker workaround etendu aux 7 inputs date restants dans 4 composants (TransactionFilterBar, AdjustmentForm, LinkTransfersModal, PeriodSelector) (ref #188)
- 2026-05-03 : postcss 8.5.6 -> 8.5.13, fix GHSA-qx2v-qp2m-jg93 (transitif via vite, build-time only) (ref #180)
- 2026-05-02 : Settings eclate en 3 sous-pages `/settings/{users,data,systems}` + redirections legacy `/docs` et `/changelog` (ref #190)
## Blockers actifs ## Blockers actifs

View file

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

426
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.10.0", "version": "0.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.10.0", "version": "0.13.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -23,7 +23,7 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.18.1",
"recharts": "^3.7.0" "recharts": "^3.7.0"
}, },
"devDependencies": { "devDependencies": {
@ -35,17 +35,18 @@
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.4.1", "vite": "^6.4.3",
"vitest": "^4.0.18" "vitest": "^4.1.9"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.28.5", "@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0", "js-tokens": "^4.0.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
@ -54,29 +55,31 @@
} }
}, },
"node_modules/@babel/compat-data": { "node_modules/@babel/compat-data": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/core": { "node_modules/@babel/core": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.28.6", "@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.28.6", "@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.7",
"@babel/template": "^7.28.6", "@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.0", "@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.0", "@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"debug": "^4.1.0", "debug": "^4.1.0",
@ -93,13 +96,14 @@
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.29.1", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.7",
"@babel/types": "^7.29.0", "@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28", "@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2" "jsesc": "^3.0.2"
@ -109,13 +113,14 @@
} }
}, },
"node_modules/@babel/helper-compilation-targets": { "node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/compat-data": "^7.28.6", "@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.27.1", "@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0", "browserslist": "^4.24.0",
"lru-cache": "^5.1.1", "lru-cache": "^5.1.1",
"semver": "^6.3.1" "semver": "^6.3.1"
@ -125,36 +130,39 @@
} }
}, },
"node_modules/@babel/helper-globals": { "node_modules/@babel/helper-globals": {
"version": "7.28.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-module-imports": { "node_modules/@babel/helper-module-imports": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/traverse": "^7.28.6", "@babel/traverse": "^7.29.7",
"@babel/types": "^7.28.6" "@babel/types": "^7.29.7"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-module-transforms": { "node_modules/@babel/helper-module-transforms": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-module-imports": "^7.28.6", "@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.28.5", "@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.28.6" "@babel/traverse": "^7.29.7"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -173,52 +181,57 @@
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.27.1", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-validator-option": { "node_modules/@babel/helper-validator-option": {
"version": "7.27.1", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.28.6", "@babel/template": "^7.29.7",
"@babel/types": "^7.28.6" "@babel/types": "^7.29.7"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.29.0" "@babel/types": "^7.29.7"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@ -266,31 +279,33 @@
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.28.6", "@babel/parser": "^7.29.7",
"@babel/types": "^7.28.6" "@babel/types": "^7.29.7"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/traverse": { "node_modules/@babel/traverse": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.28.0", "@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.7",
"@babel/template": "^7.28.6", "@babel/template": "^7.29.7",
"@babel/types": "^7.29.0", "@babel/types": "^7.29.7",
"debug": "^4.3.1" "debug": "^4.3.1"
}, },
"engines": { "engines": {
@ -298,13 +313,14 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.28.5" "@babel/helper-validator-identifier": "^7.29.7"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -1964,31 +1980,31 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.0.18", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18", "@vitest/spy": "4.1.9",
"@vitest/utils": "4.0.18", "@vitest/utils": "4.1.9",
"chai": "^6.2.1", "chai": "^6.2.2",
"tinyrainbow": "^3.0.3" "tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "4.0.18", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "4.0.18", "@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.21" "magic-string": "^0.30.21"
}, },
@ -1997,7 +2013,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"msw": "^2.4.9", "msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"msw": { "msw": {
@ -2009,26 +2025,26 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "4.0.18", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tinyrainbow": "^3.0.3" "tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "4.0.18", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.18", "@vitest/utils": "4.1.9",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@ -2036,13 +2052,14 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "4.0.18", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.0.18", "@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@ -2051,9 +2068,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "4.0.18", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@ -2061,14 +2078,15 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "4.0.18", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.0.18", "@vitest/pretty-format": "4.1.9",
"tinyrainbow": "^3.0.3" "convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
@ -2085,18 +2103,22 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.10.41",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.1", "version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2112,12 +2134,13 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001799",
"electron-to-chromium": "^1.5.263", "electron-to-chromium": "^1.5.376",
"node-releases": "^2.0.27", "node-releases": "^2.0.48",
"update-browserslist-db": "^1.2.0" "update-browserslist-db": "^1.2.3"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@ -2127,9 +2150,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001769", "version": "1.0.30001800",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2144,7 +2167,8 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
] ],
"license": "CC-BY-4.0"
}, },
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
@ -2174,6 +2198,7 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -2303,6 +2328,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@ -2330,10 +2356,11 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.286", "version": "1.5.384",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
"dev": true "dev": true,
"license": "ISC"
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.19.0", "version": "5.19.0",
@ -2349,9 +2376,9 @@
} }
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -2406,6 +2433,7 @@
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -2549,13 +2577,15 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true, "dev": true,
"license": "MIT",
"bin": { "bin": {
"jsesc": "bin/jsesc" "jsesc": "bin/jsesc"
}, },
@ -2829,6 +2859,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
@ -2854,7 +2885,8 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
@ -2875,10 +2907,14 @@
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
"dev": true "dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
}, },
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.1",
@ -3034,9 +3070,10 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.13.0", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0" "set-cookie-parser": "^2.6.0"
@ -3055,11 +3092,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.13.0", "version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.13.0" "react-router": "7.18.1"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@ -3168,6 +3206,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true, "dev": true,
"license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
@ -3175,7 +3214,8 @@
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
}, },
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
@ -3201,9 +3241,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.10.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -3265,9 +3305,9 @@
} }
}, },
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.0.3", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -3317,6 +3357,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"escalade": "^3.2.0", "escalade": "^3.2.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
@ -3358,9 +3399,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3433,31 +3474,31 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "4.0.18", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.18", "@vitest/expect": "4.1.9",
"@vitest/mocker": "4.0.18", "@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.0.18", "@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.0.18", "@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.0.18", "@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.0.18", "@vitest/spy": "4.1.9",
"@vitest/utils": "4.0.18", "@vitest/utils": "4.1.9",
"es-module-lexer": "^1.7.0", "es-module-lexer": "^2.0.0",
"expect-type": "^1.2.2", "expect-type": "^1.3.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"obug": "^2.1.1", "obug": "^2.1.1",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"std-env": "^3.10.0", "std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0", "tinybench": "^2.9.0",
"tinyexec": "^1.0.2", "tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3", "tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
@ -3473,12 +3514,15 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18", "@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.0.18", "@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.0.18", "@vitest/browser-webdriverio": "4.1.9",
"@vitest/ui": "4.0.18", "@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@edge-runtime/vm": { "@edge-runtime/vm": {
@ -3499,6 +3543,12 @@
"@vitest/browser-webdriverio": { "@vitest/browser-webdriverio": {
"optional": true "optional": true
}, },
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": { "@vitest/ui": {
"optional": true "optional": true
}, },
@ -3507,6 +3557,9 @@
}, },
"jsdom": { "jsdom": {
"optional": true "optional": true
},
"vite": {
"optional": false
} }
} }
}, },
@ -3539,7 +3592,8 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true "dev": true,
"license": "ISC"
} }
} }
} }

View file

@ -1,7 +1,7 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.10.0", "version": "0.13.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -27,7 +27,7 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.18.1",
"recharts": "^3.7.0" "recharts": "^3.7.0"
}, },
"devDependencies": { "devDependencies": {
@ -39,7 +39,12 @@
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.4.1", "vite": "^6.4.3",
"vitest": "^4.0.18" "vitest": "^4.1.9"
},
"overrides": {
"@vitejs/plugin-react": {
"@babel/core": "^7.29.7"
}
} }
} }

2
src-tauri/Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -27,9 +27,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({ vi.mock("../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),

View file

@ -14,9 +14,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({ vi.mock("../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),

View file

@ -14,9 +14,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({ vi.mock("../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "../services/db"; import { getDb } from "../services/db";
import { import {

View file

@ -1,41 +0,0 @@
// BalanceOnboardingCard — unit tests (issue #178)
//
// NOTE: This project does not have @testing-library/react or jsdom configured
// (logged as MEDIUM in autopilot decisions-log). Tests cover the pure
// `deriveOnboardingSteps` helper that drives the visual state of each step.
// All React rendering is bypassed.
import { describe, it, expect } from "vitest";
import { deriveOnboardingSteps } from "./BalanceOnboardingCard";
describe("BalanceOnboardingCard — deriveOnboardingSteps", () => {
it("0 accounts, 0 snapshots → step1 active, step2 disabled", () => {
const r = deriveOnboardingSteps(0, 0);
expect(r.step1).toBe("active");
expect(r.step2).toBe("disabled");
});
it(">=1 account, 0 snapshots → step1 done, step2 active", () => {
const r = deriveOnboardingSteps(1, 0);
expect(r.step1).toBe("done");
expect(r.step2).toBe("active");
const r2 = deriveOnboardingSteps(5, 0);
expect(r2.step1).toBe("done");
expect(r2.step2).toBe("active");
});
it(">=1 account, >=1 snapshot → both done (defensive — card normally hidden)", () => {
const r = deriveOnboardingSteps(2, 3);
expect(r.step1).toBe("done");
expect(r.step2).toBe("done");
});
it("guard: 0 accounts but >=1 snapshot (anomaly) → step1 active, step2 done", () => {
// This combination should not happen in practice (a snapshot requires at
// least one account), but the helper handles it conservatively.
const r = deriveOnboardingSteps(0, 1);
expect(r.step1).toBe("active");
expect(r.step2).toBe("done");
});
});

View file

@ -1,206 +0,0 @@
// BalanceOnboardingCard — empty-state onboarding for /balance.
//
// Issue #178. Replaces the BalanceOverviewCard when the user has no accounts
// or no snapshots yet. Two vertical steps:
// 1. Create an account → /balance/accounts
// 2. Enter a snapshot → /balance/snapshot
//
// Each step has 3 states:
// - "active": primary CTA, currently the next thing to do
// - "done": marked with a checkmark, no CTA
// - "disabled": grayed out (e.g. step 2 when 0 accounts), CTA disabled
//
// The whole card is replaced by BalanceOverviewCard once at least one
// snapshot exists, so step 2 in practice is rendered as "active" or
// "disabled"; the "done" branch is supported for completeness/tests.
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
interface BalanceOnboardingCardProps {
/** Number of active (non-archived) accounts. */
accountsCount: number;
/** Number of snapshots saved (any date). */
snapshotsCount: number;
}
export type StepState = "active" | "done" | "disabled";
/**
* Pure helper exposed for unit tests derives the state of each onboarding
* step from the (accountsCount, snapshotsCount) pair.
*
* - Step 1 is "done" once at least one account exists, "active" otherwise.
* - Step 2 is "done" once any snapshot exists, "active" once at least one
* account exists, "disabled" otherwise. In practice the parent guard on
* /balance only renders this card when snapshotsCount === 0, so the
* "done" branch for step 2 is mostly defensive.
*/
export function deriveOnboardingSteps(
accountsCount: number,
snapshotsCount: number
): { step1: StepState; step2: StepState } {
const step1: StepState = accountsCount >= 1 ? "done" : "active";
const step2: StepState =
snapshotsCount >= 1
? "done"
: accountsCount >= 1
? "active"
: "disabled";
return { step1, step2 };
}
export default function BalanceOnboardingCard({
accountsCount,
snapshotsCount,
}: BalanceOnboardingCardProps) {
const { t } = useTranslation();
const { step1: step1State, step2: step2State } = deriveOnboardingSteps(
accountsCount,
snapshotsCount
);
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h2 className="text-lg font-semibold mb-1">
{t("balance.onboarding.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-5">
{t("balance.onboarding.subtitle")}
</p>
<ol className="space-y-3">
<Step
number={1}
state={step1State}
icon={<Wallet size={18} />}
title={t("balance.onboarding.step1.title")}
description={t("balance.onboarding.step1.description")}
ctaLabel={t("balance.onboarding.step1.cta")}
ctaHref="/balance/accounts"
/>
<Step
number={2}
state={step2State}
icon={<FileText size={18} />}
title={t("balance.onboarding.step2.title")}
description={t("balance.onboarding.step2.description")}
ctaLabel={t("balance.onboarding.step2.cta")}
ctaHref="/balance/snapshot"
disabledHint={t("balance.onboarding.step2.disabledHint")}
/>
</ol>
</div>
);
}
// -----------------------------------------------------------------------------
// Internal — single step row
// -----------------------------------------------------------------------------
interface StepProps {
number: number;
state: StepState;
icon: React.ReactNode;
title: string;
description: string;
ctaLabel: string;
ctaHref: string;
disabledHint?: string;
}
function Step({
number,
state,
icon,
title,
description,
ctaLabel,
ctaHref,
disabledHint,
}: StepProps) {
const { t } = useTranslation();
const isDone = state === "done";
const isActive = state === "active";
const isDisabled = state === "disabled";
// Number bubble: green check when done, primary bg when active, muted when disabled.
const bubbleClass = isDone
? "bg-[var(--positive)] text-white"
: isActive
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)]";
const titleClass = isDisabled
? "text-[var(--muted-foreground)]"
: "text-[var(--foreground)]";
return (
<li
data-testid={`balance-onboarding-step-${number}`}
data-state={state}
className={`flex items-start gap-4 p-4 rounded-lg border ${
isDisabled
? "border-[var(--border)] opacity-60"
: "border-[var(--border)]"
}`}
>
<div
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${bubbleClass}`}
aria-hidden="true"
>
{isDone ? <Check size={16} /> : number}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[var(--muted-foreground)]" aria-hidden="true">
{icon}
</span>
<h3 className={`text-sm font-semibold ${titleClass}`}>{title}</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
{isDisabled && disabledHint && (
<p className="text-xs text-[var(--muted-foreground)] italic mt-1">
{disabledHint}
</p>
)}
</div>
<div className="shrink-0 self-center">
{isDone ? (
<span
className="inline-flex items-center gap-1 text-xs text-[var(--positive)] font-medium"
data-testid={`balance-onboarding-step-${number}-done-badge`}
>
<Check size={14} />
{t("balance.onboarding.doneBadge")}
</span>
) : isActive ? (
<Link
to={ctaHref}
data-testid={`balance-onboarding-step-${number}-cta`}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
{ctaLabel}
<ArrowRight size={14} />
</Link>
) : (
<button
type="button"
disabled
data-testid={`balance-onboarding-step-${number}-cta`}
aria-disabled="true"
title={disabledHint}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] text-sm font-medium cursor-not-allowed"
>
{ctaLabel}
<ArrowRight size={14} />
</button>
)}
</div>
</li>
);
}

View file

@ -0,0 +1,351 @@
// HoldingsCsvImportModal — bulk-import a detailed account's positions from a
// CSV instead of typing them one by one (Issue #245).
//
// Flow (all frontend — the save path is unchanged):
// 1. pick a CSV file (native dialog, reusing the existing `pick_import_file`
// Rust command with a CSV filter);
// 2. detect its encoding (`detect_encoding`, utf-8 fallback) + read it
// (`read_file_content`);
// 3. `analyzeHoldingsCsv` preprocesses, detects the delimiter/header and the
// symbol / quantity / unit_price / book_cost columns (flexible price
// detection: no price column ⇒ mapping stays null);
// 4. the user reviews/adjusts the mapping in a small editor + preview;
// 5. on confirm we hand the DATA rows + mapping up to the editor hook, which
// builds `HoldingDraft`s (FR numbers, duplicate-symbol merge) and merges
// them into the account's basket. The user then fetches/types any missing
// price and saves through the normal atomic path.
//
// The component owns the file/parse lifecycle; the actual draft building and
// basket merge live in `useSnapshotEditor` so they stay pure + unit-tested.
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core";
import { Upload, X, AlertTriangle, FileSpreadsheet } from "lucide-react";
import {
analyzeHoldingsCsv,
type HoldingColumnMapping,
type HoldingCsvAnalysis,
} from "../../utils/csvAutoDetect";
import { normalizeSecuritySymbol } from "../../services/balance.service";
interface Props {
accountName: string;
/** Called with the parsed DATA rows + the confirmed column mapping. */
onImport: (rows: string[][], mapping: HoldingColumnMapping) => void;
onClose: () => void;
}
type Step = "select" | "loading" | "map" | "error";
const PREVIEW_ROWS = 5;
export default function HoldingsCsvImportModal({
accountName,
onImport,
onClose,
}: Props) {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("select");
const [analysis, setAnalysis] = useState<HoldingCsvAnalysis | null>(null);
const [mapping, setMapping] = useState<HoldingColumnMapping | null>(null);
const [errorMsg, setErrorMsg] = useState<string>("");
const pickAndAnalyze = useCallback(async () => {
setStep("loading");
setErrorMsg("");
try {
const filePath = await invoke<string | null>("pick_import_file", {
filters: [["CSV", ["csv", "txt"]]],
});
if (!filePath) {
// User cancelled the native dialog — return to the select step.
setStep("select");
return;
}
let encoding = "utf-8";
try {
encoding = await invoke<string>("detect_encoding", {
filePath,
});
} catch {
// fall back to utf-8
}
const content = await invoke<string>("read_file_content", {
filePath,
encoding,
});
const result = analyzeHoldingsCsv(content);
if (!result) {
setErrorMsg(t("balance.snapshot.detailed.importCsv.detectError"));
setStep("error");
return;
}
setAnalysis(result);
setMapping(result.mapping);
setStep("map");
} catch (e) {
setErrorMsg(
e instanceof Error
? e.message
: t("balance.snapshot.detailed.importCsv.readError")
);
setStep("error");
}
}, [t]);
const columnOptions = useMemo(() => {
if (!analysis) return null;
return analysis.headers.map((h, i) => (
<option key={i} value={i}>
{i}: {h}
</option>
));
}, [analysis]);
const preview = useMemo(() => {
if (!analysis || !mapping) return [];
return analysis.rows.slice(0, PREVIEW_ROWS).map((row) => {
const symbol = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim());
const qty = (row[mapping.quantity] ?? "").trim();
const price =
mapping.unit_price !== null
? (row[mapping.unit_price] ?? "").trim()
: "";
const book =
mapping.book_cost !== null ? (row[mapping.book_cost] ?? "").trim() : "";
return { symbol, qty, price, book };
});
}, [analysis, mapping]);
// Count the distinct, non-blank symbols that would be imported (after merge).
const importableCount = useMemo(() => {
if (!analysis || !mapping) return 0;
const seen = new Set<string>();
for (const row of analysis.rows) {
const s = normalizeSecuritySymbol((row[mapping.symbol] ?? "").trim());
if (s) seen.add(s);
}
return seen.size;
}, [analysis, mapping]);
const handleImport = () => {
if (!analysis || !mapping) return;
onImport(analysis.rows, mapping);
onClose();
};
const selectClass =
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]";
const setMappingField = (
field: keyof HoldingColumnMapping,
raw: string
) => {
if (!mapping) return;
const parsed = parseInt(raw, 10);
const next: number | null =
Number.isNaN(parsed) || parsed < 0 ? null : parsed;
setMapping({ ...mapping, [field]: next });
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-2 min-w-0">
<FileSpreadsheet size={20} className="text-[var(--primary)]" />
<h2 className="text-lg font-semibold truncate">
{t("balance.snapshot.detailed.importCsv.title")}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
aria-label={t("common.cancel")}
>
<X size={18} />
</button>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
{t("balance.snapshot.detailed.importCsv.intro", {
account: accountName,
})}
</p>
{(step === "select" || step === "loading") && (
<div className="flex flex-col items-center gap-3 py-6">
<button
type="button"
onClick={pickAndAnalyze}
disabled={step === "loading"}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
<Upload size={16} />
{step === "loading"
? t("balance.snapshot.detailed.importCsv.loading")
: t("balance.snapshot.detailed.importCsv.selectFile")}
</button>
<p className="text-xs text-[var(--muted-foreground)] text-center">
{t("balance.snapshot.detailed.importCsv.hint")}
</p>
</div>
)}
{step === "error" && (
<div className="py-4">
<div className="flex items-start gap-2 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>{errorMsg}</span>
</div>
<div className="flex justify-end mt-4">
<button
type="button"
onClick={() => setStep("select")}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("balance.snapshot.detailed.importCsv.retry")}
</button>
</div>
</div>
)}
{step === "map" && analysis && mapping && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
{t("balance.snapshot.detailed.importCsv.symbolColumn")}
</label>
<select
value={mapping.symbol}
onChange={(e) => setMappingField("symbol", e.target.value)}
className={selectClass}
>
{columnOptions}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
{t("balance.snapshot.detailed.importCsv.quantityColumn")}
</label>
<select
value={mapping.quantity}
onChange={(e) => setMappingField("quantity", e.target.value)}
className={selectClass}
>
{columnOptions}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
{t("balance.snapshot.detailed.importCsv.priceColumn")}
</label>
<select
value={mapping.unit_price ?? -1}
onChange={(e) => setMappingField("unit_price", e.target.value)}
className={selectClass}
>
<option value={-1}>
{t("balance.snapshot.detailed.importCsv.noColumn")}
</option>
{columnOptions}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-1">
{t("balance.snapshot.detailed.importCsv.bookCostColumn")}
</label>
<select
value={mapping.book_cost ?? -1}
onChange={(e) => setMappingField("book_cost", e.target.value)}
className={selectClass}
>
<option value={-1}>
{t("balance.snapshot.detailed.importCsv.noColumn")}
</option>
{columnOptions}
</select>
</div>
</div>
{mapping.unit_price === null && (
<p className="text-xs text-[var(--muted-foreground)] italic">
{t("balance.snapshot.detailed.importCsv.noPriceNote")}
</p>
)}
{/* Preview of the first rows as they will be imported. */}
<div className="rounded-lg border border-[var(--border)] overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="bg-[var(--muted)] text-left">
<th className="px-2 py-1.5 font-medium">
{t("balance.snapshot.detailed.col.title")}
</th>
<th className="px-2 py-1.5 font-medium text-right">
{t("balance.snapshot.detailed.col.quantity")}
</th>
<th className="px-2 py-1.5 font-medium text-right">
{t("balance.snapshot.detailed.col.unitPrice")}
</th>
<th className="px-2 py-1.5 font-medium text-right">
{t("balance.snapshot.detailed.col.bookCost")}
</th>
</tr>
</thead>
<tbody>
{preview.map((r, i) => (
<tr
key={i}
className="border-t border-[var(--border)] tabular-nums"
>
<td className="px-2 py-1.5 font-medium">
{r.symbol || "—"}
</td>
<td className="px-2 py-1.5 text-right">{r.qty || "—"}</td>
<td className="px-2 py-1.5 text-right">
{r.price || "—"}
</td>
<td className="px-2 py-1.5 text-right">{r.book || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{t("balance.snapshot.detailed.importCsv.summary", {
count: importableCount,
})}
</p>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleImport}
disabled={importableCount === 0}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
<Upload size={14} />
{t("balance.snapshot.detailed.importCsv.import")}
</button>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -14,6 +14,7 @@ import type {
BalanceSecurity, BalanceSecurity,
} from "../../shared/types"; } from "../../shared/types";
import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
import type { SecurityPick } from "./SecurityPicker"; import type { SecurityPick } from "./SecurityPicker";
import SnapshotLineRow from "./SnapshotLineRow"; import SnapshotLineRow from "./SnapshotLineRow";
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel"; import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
@ -42,6 +43,12 @@ interface Props {
rowId: string, rowId: string,
pick: SecurityPick pick: SecurityPick
) => void; ) => void;
/** Import a batch of holdings from a CSV into a detailed account (#245). */
onImportHoldings: (
accountId: number,
rows: string[][],
mapping: HoldingColumnMapping
) => void;
disabled?: boolean; disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */ /** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
snapshotDate?: string; snapshotDate?: string;
@ -58,6 +65,7 @@ export default function SnapshotEditor({
onRemoveHolding, onRemoveHolding,
onHoldingFieldChange, onHoldingFieldChange,
onHoldingSecurityPick, onHoldingSecurityPick,
onImportHoldings,
disabled, disabled,
snapshotDate, snapshotDate,
}: Props) { }: Props) {
@ -124,6 +132,9 @@ export default function SnapshotEditor({
onHoldingSecurityPick={(rowId, pick) => onHoldingSecurityPick={(rowId, pick) =>
onHoldingSecurityPick(acc.id, rowId, pick) onHoldingSecurityPick(acc.id, rowId, pick)
} }
onImportHoldings={(rows, mapping) =>
onImportHoldings(acc.id, rows, mapping)
}
disabled={disabled} disabled={disabled}
snapshotDate={snapshotDate} snapshotDate={snapshotDate}
/> />

View file

@ -25,16 +25,18 @@
// strings on every change. Numeric validation happens at save time in // strings on every change. Numeric validation happens at save time in
// `useSnapshotEditor.save`. // `useSnapshotEditor.save`.
import { ChangeEvent, useMemo } from "react"; import { ChangeEvent, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2, Upload } from "lucide-react";
import type { import type {
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceSecurity, BalanceSecurity,
} from "../../shared/types"; } from "../../shared/types";
import type { HoldingDraft } from "../../hooks/useSnapshotEditor"; import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
import type { HoldingColumnMapping } from "../../utils/csvAutoDetect";
import PriceFetchControl from "./PriceFetchControl"; import PriceFetchControl from "./PriceFetchControl";
import SecurityPicker, { type SecurityPick } from "./SecurityPicker"; import SecurityPicker, { type SecurityPick } from "./SecurityPicker";
import HoldingsCsvImportModal from "./HoldingsCsvImportModal";
interface Props { interface Props {
account: BalanceAccountWithCategory; account: BalanceAccountWithCategory;
@ -57,6 +59,8 @@ interface Props {
) => void; ) => void;
/** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */ /** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */
onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void; onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void;
/** Import a batch of holdings from a CSV into this detailed account (#245). */
onImportHoldings?: (rows: string[][], mapping: HoldingColumnMapping) => void;
} }
/** /**
@ -84,9 +88,11 @@ export default function SnapshotLineRow({
onRemoveHolding, onRemoveHolding,
onHoldingFieldChange, onHoldingFieldChange,
onHoldingSecurityPick, onHoldingSecurityPick,
onImportHoldings,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const isDetailed = account.kind === "detailed"; const isDetailed = account.kind === "detailed";
const [importOpen, setImportOpen] = useState(false);
// Account total across the basket (live as the user types). // Account total across the basket (live as the user types).
const detailedTotal = useMemo(() => { const detailedTotal = useMemo(() => {
@ -152,15 +158,36 @@ export default function SnapshotLineRow({
</div> </div>
)} )}
<div className="mt-2 flex items-center gap-4">
<button <button
type="button" type="button"
onClick={() => onAddHolding?.()} onClick={() => onAddHolding?.()}
disabled={disabled} disabled={disabled}
className="mt-2 inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50" className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
> >
<Plus size={13} /> <Plus size={13} />
{t("balance.snapshot.detailed.addTitle")} {t("balance.snapshot.detailed.addTitle")}
</button> </button>
{onImportHoldings && (
<button
type="button"
onClick={() => setImportOpen(true)}
disabled={disabled}
className="inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
>
<Upload size={13} />
{t("balance.snapshot.detailed.importCsv.button")}
</button>
)}
</div>
{importOpen && onImportHoldings && (
<HoldingsCsvImportModal
accountName={account.name}
onImport={onImportHoldings}
onClose={() => setImportOpen(false)}
/>
)}
</div> </div>
); );
} }

View file

@ -8,9 +8,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../services/db", () => ({ vi.mock("../../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "../../services/db"; import { getDb } from "../../services/db";
import { import {

View file

@ -0,0 +1,30 @@
// balanceLanding — unit tests (issue #244)
//
// Covers the pure `deriveLandingState` helper that replaces the old boolean
// empty-state guard. No React rendering (this project has no jsdom configured);
// the decoupled-guard logic is exercised directly as a pure helper.
import { describe, it, expect } from "vitest";
import { deriveLandingState } from "./balanceLanding";
describe("balanceLanding — deriveLandingState", () => {
it("0 accounts → empty (regardless of snapshot flag)", () => {
expect(deriveLandingState(0, false)).toBe("empty");
// Anomalous (a snapshot implies an account) but handled conservatively.
expect(deriveLandingState(0, true)).toBe("empty");
});
it(">=1 account but no snapshot → accounts-no-snapshot (no longer 'empty')", () => {
expect(deriveLandingState(1, false)).toBe("accounts-no-snapshot");
expect(deriveLandingState(5, false)).toBe("accounts-no-snapshot");
});
it(">=1 account with a snapshot → populated", () => {
expect(deriveLandingState(1, true)).toBe("populated");
expect(deriveLandingState(12, true)).toBe("populated");
});
it("guards against negative counts → empty", () => {
expect(deriveLandingState(-1, true)).toBe("empty");
});
});

View file

@ -0,0 +1,37 @@
// balanceLanding.ts — pure landing-state helper for the /balance page.
//
// Issue #244. Decouples the old boolean empty-state guard
// (`accountsCount === 0 || !hasAnySnapshot`) into three explicit states so the
// UI can stop conflating "no account yet" with "accounts but no snapshot yet":
//
// - "empty": no active account at all → offer account creation
// (StarterAccountsModal + a "Manage accounts" tile).
// - "accounts-no-snapshot": at least one account but no snapshot → offer BOTH
// "Manage accounts" and "New snapshot" tiles, without
// forcing the user toward the snapshot flow.
// - "populated": at least one account with at least one snapshot →
// render the full dashboard (overview + chart +
// accounts table) plus the navigation tiles.
//
// Extracted as a pure, DOM-free helper because this project has no
// @testing-library/react or jsdom configured — UI branching is unit-tested via
// pure helpers (same convention as `deriveOnboardingSteps` / `computeBalanceDateRange`).
export type BalanceLandingState = "empty" | "accounts-no-snapshot" | "populated";
/**
* Derive the landing state from the two independent signals the balance page
* already computes: the number of active accounts and whether ANY snapshot
* exists (probed across accounts, independent of the active period filter).
*
* @param accountsCount number of active (non-archived) accounts
* @param hasAnySnapshot true when at least one account carries a snapshot date
*/
export function deriveLandingState(
accountsCount: number,
hasAnySnapshot: boolean
): BalanceLandingState {
if (accountsCount <= 0) return "empty";
if (!hasAnySnapshot) return "accounts-no-snapshot";
return "populated";
}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; import CategoryCombobox from "../shared/CategoryCombobox";
import { comboboxCategoriesForTarget } from "./migrationTargets";
import type { Category } from "../../shared/types";
import type { import type {
MappingRow as MappingRowType, MappingRow as MappingRowType,
ConfidenceBadge, ConfidenceBadge,
@ -13,13 +15,27 @@ interface MappingRowProps {
/** Callback fired when the row is clicked — opens the preview panel. */ /** Callback fired when the row is clicked — opens the preview panel. */
onSelect: (v2CategoryId: number) => void; onSelect: (v2CategoryId: number) => void;
/** /**
* Called with the new v1 target id + name when the user resolves the row * Called with the new v1 target id + name when the user picks a target via
* via the inline dropdown. The dropdown is only rendered for unresolved * the inline type-ahead combobox. Editable on EVERY row (resolved or not),
* ("🟠 needs review") rows resolved rows just show the target name. * so a user can override an auto-detected target as well as resolve a
* "needs review" one.
*/ */
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void; onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
/** Number of transactions currently attached to this v2 category. */ /** Number of transactions currently attached to this v2 category. */
transactionCount: number; transactionCount: number;
/**
* v1 LEAF catalogue adapted to the `Category` shape the combobox expects.
* Computed ONCE by StepSimulate and shared across rows (perf). Leaves only
* a transaction is never mapped to a grouping bucket.
*/
targetCategories: Category[];
/**
* Resolves a target id (possibly a non-leaf parent absent from the leaves-only
* `targetCategories`) to a display `Category`. Used to keep a low-confidence
* parent default like "Divertissement" (#1710) visible on its row without
* offering parents as new picks.
*/
resolveTarget: (id: number) => Category | null;
} }
function badgeClass(confidence: ConfidenceBadge): string { function badgeClass(confidence: ConfidenceBadge): string {
@ -41,14 +57,21 @@ export default function MappingRow({
onSelect, onSelect,
onResolve, onResolve,
transactionCount, transactionCount,
targetCategories,
resolveTarget,
}: MappingRowProps) { }: MappingRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getLeaves } = useCategoryTaxonomy();
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the // The picker lists leaves only. If this row's current target is a non-leaf
// list flat because the simulate row is narrow; the search box in step 2 // parent (a low-confidence default like "Divertissement" #1710), it is absent
// already helps users find a target by keyword. // from that list and would blank the combobox input, so re-inject it for this
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]); // row so it stays visible. It remains clickable, but selecting it is a no-op —
// handleTargetChange only resolves ids present in `targetCategories` (leaves) —
// so any pick that takes effect is a leaf. See comboboxCategoriesForTarget.
const comboboxCategories = useMemo(
() => comboboxCategoriesForTarget(targetCategories, row.v1TargetId ?? null, resolveTarget),
[targetCategories, row.v1TargetId, resolveTarget],
);
const badgeLabel = t( const badgeLabel = t(
`categoriesSeed.migration.simulate.confidence.${row.confidence}`, `categoriesSeed.migration.simulate.confidence.${row.confidence}`,
@ -59,12 +82,18 @@ export default function MappingRow({
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined; const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => { // Fired when the user picks a v1 leaf in the type-ahead combobox. The
const v1TargetId = Number(ev.target.value); // combobox only emits ids that exist in `targetCategories` (never null with
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return; // our config), but we guard defensively. Resolving a "none" row bumps its
const leaf = v1Leaves.find((l) => l.id === v1TargetId); // confidence to "medium" via the reducer; editing an already-resolved row
if (!leaf) return; // keeps its confidence unchanged (see RESOLVE_ROW).
const name = t(leaf.i18n_key, { defaultValue: leaf.name }); const handleTargetChange = (v1TargetId: number | null) => {
if (v1TargetId === null) return;
const target = targetCategories.find((c) => c.id === v1TargetId);
if (!target) return;
const name = target.i18n_key
? t(target.i18n_key, { defaultValue: target.name })
: target.name;
onResolve(row.v2CategoryId, v1TargetId, name); onResolve(row.v2CategoryId, v1TargetId, name);
}; };
@ -119,32 +148,26 @@ export default function MappingRow({
</span> </span>
</div> </div>
{/* v1 target (or picker) */} {/* v1 target editable type-ahead combobox on EVERY row. Stop click +
keydown from bubbling so interacting with the picker (typing spaces,
Enter to select) never triggers the row's select/preview handler. */}
<div <div
className="col-span-5 flex items-center justify-end gap-2 min-w-0" className="col-span-5 flex items-center justify-end min-w-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
> >
{isUnresolved ? ( <div className="w-full max-w-[16rem]">
<select <CategoryCombobox
value="" categories={comboboxCategories}
onChange={handleResolveChange} value={row.v1TargetId ?? null}
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")} onChange={handleTargetChange}
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30" compact
> placeholder={t("categoriesSeed.migration.simulate.chooseTarget")}
<option value="" disabled> ariaLabel={t("categoriesSeed.migration.simulate.editTargetAria", {
{t("categoriesSeed.migration.simulate.chooseTarget")} category: row.v2CategoryName,
</option> })}
{v1Leaves.map((leaf) => ( />
<option key={leaf.id} value={leaf.id}> </div>
{t(leaf.i18n_key, { defaultValue: leaf.name })}
</option>
))}
</select>
) : (
<span className="truncate text-[var(--foreground)]">
{targetDisplayName}
</span>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,8 +1,13 @@
import { useMemo } from "react"; import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react"; import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
import MappingRow from "./MappingRow"; import MappingRow from "./MappingRow";
import TransactionPreviewPanel from "./TransactionPreviewPanel"; import TransactionPreviewPanel from "./TransactionPreviewPanel";
import {
taxonomyToComboboxCategories,
findTaxonomyCategory,
} from "./migrationTargets";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import type { import type {
MigrationPlan, MigrationPlan,
MappingRow as MappingRowType, MappingRow as MappingRowType,
@ -36,6 +41,24 @@ export default function StepSimulate({
onBack, onBack,
}: StepSimulateProps) { }: StepSimulateProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy();
// v1 LEAF catalogue adapted to the combobox `Category` shape. Computed once
// here (not per row) and shared across every MappingRow's target picker —
// only leaves are offered, so a transaction is never mapped to a grouping
// bucket.
const targetCategories = useMemo(
() => taxonomyToComboboxCategories(taxonomy.roots),
[taxonomy],
);
// Resolve a target id (possibly a non-leaf parent absent from the leaves-only
// list) to a display Category, so a row keeps showing a low-confidence parent
// default like "Divertissement" (#1710) without offering parents as picks.
const resolveTarget = useCallback(
(id: number) => findTaxonomyCategory(taxonomy.roots, id),
[taxonomy],
);
const selectedRow = useMemo<MappingRowType | null>(() => { const selectedRow = useMemo<MappingRowType | null>(() => {
if (selectedRowV2Id === null) return null; if (selectedRowV2Id === null) return null;
@ -149,6 +172,8 @@ export default function StepSimulate({
transactionCount={ transactionCount={
transactionCountByV2Id.get(row.v2CategoryId) ?? 0 transactionCountByV2Id.get(row.v2CategoryId) ?? 0
} }
targetCategories={targetCategories}
resolveTarget={resolveTarget}
/> />
</li> </li>
))} ))}

View file

@ -0,0 +1,177 @@
import { describe, it, expect } from "vitest";
import {
taxonomyToComboboxCategories,
findTaxonomyCategory,
comboboxCategoriesForTarget,
} from "./migrationTargets";
import type { Category } from "../../shared/types";
import {
getTaxonomyV1,
type TaxonomyNode,
} from "../../services/categoryTaxonomyService";
function node(
id: number,
name: string,
children: TaxonomyNode[] = [],
type: "expense" | "income" | "transfer" = "expense",
): TaxonomyNode {
return {
id,
name,
i18n_key: `categoriesSeed.test.${id}`,
type,
color: "#123456",
sort_order: 1,
children,
};
}
describe("taxonomyToComboboxCategories (leaves only)", () => {
it("returns [] for empty input", () => {
expect(taxonomyToComboboxCategories([])).toEqual([]);
});
it("keeps only leaves, dropping every intermediate parent, in DFS order", () => {
const roots = [
node(1000, "Revenus", [
node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
]),
node(1100, "Alimentation", [node(1111, "Épicerie")]),
];
const out = taxonomyToComboboxCategories(roots);
// parents 1000/1010/1100 dropped; leaves kept in depth-first reading order
expect(out.map((c) => c.id)).toEqual([1011, 1012, 1111]);
});
it("flattens leaves: parent_id undefined + running sort_order (flat list)", () => {
const roots = [
node(1000, "Revenus", [
node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
]),
node(1100, "Alimentation", [node(1111, "Épicerie")]),
];
const out = taxonomyToComboboxCategories(roots);
expect(out.every((c) => c.parent_id === undefined)).toBe(true);
expect(out.map((c) => c.sort_order)).toEqual([0, 1, 2]);
});
it("marks every returned row inputable (all leaves)", () => {
const roots = [
node(1700, "Loisirs", [
node(1710, "Divertissement", [node(1711, "Cinéma")]),
]),
];
const out = taxonomyToComboboxCategories(roots);
expect(out.map((c) => c.id)).toEqual([1711]); // 1700 + 1710 parents dropped
expect(out.every((c) => c.is_inputable)).toBe(true);
});
it("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => {
const [c] = taxonomyToComboboxCategories([
node(1111, "Épicerie", [], "expense"),
]);
expect(c).toMatchObject({
id: 1111,
name: "Épicerie",
type: "expense",
color: "#123456",
i18n_key: "categoriesSeed.test.1111",
is_active: true,
is_inputable: true,
});
expect(typeof c.created_at).toBe("string");
});
it("adapts the real v1 taxonomy: 112 leaves, ids unique, NO non-leaf parent offered", () => {
const out = taxonomyToComboboxCategories(getTaxonomyV1().roots);
expect(out.length).toBe(112); // leaves only (was 150 incl. 38 parents)
const ids = new Set(out.map((c) => c.id));
expect(ids.size).toBe(out.length);
// The non-leaf parent 1710 "Divertissement" must NOT be a selectable option.
expect(out.find((c) => c.id === 1710)).toBeUndefined();
// A known leaf is present and inputable.
expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true);
expect(out.every((c) => c.is_inputable)).toBe(true);
});
});
describe("findTaxonomyCategory", () => {
const roots = [
node(1700, "Loisirs", [
node(1710, "Divertissement", [node(1711, "Cinéma")]),
]),
];
it("resolves a non-leaf parent by id (to display a current target)", () => {
const c = findTaxonomyCategory(roots, 1710);
expect(c).toMatchObject({
id: 1710,
name: "Divertissement",
is_inputable: false,
});
expect(c!.parent_id).toBeUndefined();
});
it("resolves a leaf too", () => {
expect(findTaxonomyCategory(roots, 1711)).toMatchObject({
id: 1711,
is_inputable: true,
});
});
it("returns null for an unknown id", () => {
expect(findTaxonomyCategory(roots, 9999)).toBeNull();
});
it("finds the real low-confidence default target 1710 in the bundled taxonomy", () => {
const c = findTaxonomyCategory(getTaxonomyV1().roots, 1710);
expect(c).toMatchObject({ id: 1710, is_inputable: false });
});
});
describe("comboboxCategoriesForTarget", () => {
const leaf = (id: number): Category => ({
id,
name: `Leaf ${id}`,
parent_id: undefined,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
created_at: "",
});
const leaves = [leaf(1111), leaf(1121), leaf(1131)];
const parent1710: Category = {
id: 1710,
name: "Divertissement",
parent_id: undefined,
type: "expense",
is_active: true,
is_inputable: false,
sort_order: Number.MAX_SAFE_INTEGER,
created_at: "",
};
const resolveTarget = (id: number) => (id === 1710 ? parent1710 : null);
it("returns the leaves list unchanged (same reference) when target is null", () => {
expect(comboboxCategoriesForTarget(leaves, null, resolveTarget)).toBe(leaves);
});
it("returns the leaves list unchanged (same reference) when target is already a leaf", () => {
expect(comboboxCategoriesForTarget(leaves, 1121, resolveTarget)).toBe(leaves);
});
it("appends the resolved non-leaf parent when the target is a parent absent from leaves", () => {
const out = comboboxCategoriesForTarget(leaves, 1710, resolveTarget);
expect(out).toHaveLength(leaves.length + 1);
expect(out[out.length - 1]).toMatchObject({ id: 1710, is_inputable: false });
// originals preserved, in order
expect(out.slice(0, 3).map((c) => c.id)).toEqual([1111, 1121, 1131]);
});
it("returns the leaves list unchanged when the non-leaf target cannot be resolved", () => {
// targetId absent from leaves AND resolver returns null (stale id) -> no injection
expect(comboboxCategoriesForTarget(leaves, 9999, resolveTarget)).toBe(leaves);
});
});

View file

@ -0,0 +1,110 @@
import type { Category } from "../../shared/types";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
/**
* Flatten the v1 taxonomy into `Category`-shaped rows for the migration target
* picker, keeping ONLY the leaves the inputable end-categories a transaction
* can actually be filed under. Intermediate parents (grouping buckets) are
* dropped so the type-ahead offers real targets only, never a bucket.
*
* Leaves are emitted in depth-first reading order and carry a running
* `sort_order` with `parent_id` undefined, so `CategoryCombobox` renders them as
* one flat, un-indented list in taxonomy order (leaves stay grouped under their
* original parent) instead of re-scrambling them by the per-parent sort_order.
*
* Every leaf id is `is_inputable` in the DB after the migration, so any pick is
* FK-safe.
*
* Edge case: a low-confidence default can point a v2 category at a NON-leaf
* parent (e.g. v2 "Jeux, Films & Livres" -> v1 "Divertissement" #1710). Such a
* value is absent from this leaves-only list; `MappingRow` re-injects it for that
* single row (via `findTaxonomyCategory`) so the input still shows the current
* suggestion, while new picks stay leaves-only.
*
* Pure helper no i18n, no DB, no React.
*/
export function taxonomyToComboboxCategories(roots: TaxonomyNode[]): Category[] {
const out: Category[] = [];
let order = 0;
const walk = (node: TaxonomyNode): void => {
if (node.children.length === 0) {
out.push({
id: node.id,
name: node.name,
parent_id: undefined,
color: node.color,
type: node.type,
is_active: true,
is_inputable: true,
sort_order: order++,
i18n_key: node.i18n_key,
created_at: "",
});
return;
}
for (const child of node.children) walk(child);
};
for (const root of roots) walk(root);
return out;
}
/**
* Resolve any taxonomy node (leaf OR parent) by id into the `Category` shape,
* for DISPLAY of a current non-leaf target that the leaves-only picker omits.
* `sort_order` is pushed to the end so, when injected, the node sorts last.
* Returns null when the id is absent from the taxonomy. Pure helper.
*/
export function findTaxonomyCategory(
roots: TaxonomyNode[],
id: number,
): Category | null {
const stack: TaxonomyNode[] = [...roots];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.id === id) {
return {
id: node.id,
name: node.name,
parent_id: undefined,
color: node.color,
type: node.type,
is_active: true,
is_inputable: node.children.length === 0,
sort_order: Number.MAX_SAFE_INTEGER,
i18n_key: node.i18n_key,
created_at: "",
};
}
for (const child of node.children) stack.push(child);
}
return null;
}
/**
* Build the target-picker option list for one migration row.
*
* Returns the leaves-only `leafOptions` unchanged when the row's current target
* is null or already a leaf (the common case same array reference, so the
* combobox never re-renders needlessly).
*
* When the target is a NON-leaf parent absent from the leaves list (a
* low-confidence default like "Divertissement" #1710), the resolved parent is
* appended so the combobox can DISPLAY it as the current value. It stays visible
* (and, since `CategoryCombobox` renders every option, clickable) but selecting
* it is a no-op: the row's resolve handler only accepts ids present in the
* leaves list, so re-picking the parent changes nothing and the user is steered
* to a leaf. New picks that take effect are therefore always leaves.
*
* Pure helper no React, no i18n, no DB.
*/
export function comboboxCategoriesForTarget(
leafOptions: Category[],
targetId: number | null,
resolveTarget: (id: number) => Category | null,
): Category[] {
if (targetId == null || leafOptions.some((c) => c.id === targetId)) {
return leafOptions;
}
const current = resolveTarget(targetId);
return current ? [...leafOptions, current] : leafOptions;
}

View file

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

View file

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

View file

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

View file

@ -1,5 +1,15 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CategoryOverTimeData } from "../../shared/types"; import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
type OverTimeRenderSection,
} from "./overTimeTableModel";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
@ -10,14 +20,40 @@ function formatMonth(month: string): string {
return date.toLocaleDateString("default", { month: "short", year: "2-digit" }); return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
} }
interface CategoryOverTimeTableProps { /** Colours a result figure by sign: surplus green, deficit red, zero neutral. */
data: CategoryOverTimeData; function resultColor(value: number): string {
hiddenCategories?: Set<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(); const { t } = useTranslation();
// Collapse/expand of sub-category groups — collapsed by default (issue #265),
// persisted under a key distinct from the comparable tables. Called before the
// early return so the hook order stays stable.
const groups = useCollapsibleGroups<OverTimeRow>(
OVERTIME_EXPANDED_STORAGE_KEY,
OVERTIME_COLLAPSE_ACCESSORS,
);
if (data.data.length === 0) { if (data.data.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
@ -26,14 +62,163 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
); );
} }
const visibleCategories = hiddenCategories?.size // Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
? data.categories.filter((name) => !hiddenCategories.has(name)) // the result lines are exact, and homonym categories never collide.
: data.categories; const analysis = computeOverTimeResults(data);
const { months, hasTransfers, net, beforeTransfers } = analysis;
const { 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 ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{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)" }}> <div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap"> <table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
@ -52,57 +237,14 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleCategories.map((category) => { {nonTransferSections.map(renderSection)}
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0); {/* Operating result (revenues expenses), interleaved before the
return ( transfers section only when transfers exist otherwise it equals
<tr key={category} className="border-b border-[var(--border)]/50"> the net total below and would just be noise. */}
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10"> {hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
<span className="flex items-center gap-2"> {transferSection && renderSection(transferSection)}
<span {/* Bottom line: result after netting transfers. */}
className="w-2.5 h-2.5 rounded-full shrink-0" {renderResultRow("reports.compare.resultNet", net, true)}
style={{ backgroundColor: data.colors[category] }}
/>
{category}
</span>
</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
return (
<td key={month} className="text-right px-3 py-1.5">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
{cadFormatter(rowTotal)}
</td>
</tr>
);
})}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const monthTotal = visibleCategories.reduce(
(sum, cat) => sum + ((monthData as Record<string, unknown>)?.[cat] as number || 0),
0,
);
return (
<td key={month} className="text-right px-3 py-3">
{cadFormatter(monthTotal)}
</td>
);
})}
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(
visibleCategories.reduce(
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
0,
),
)}
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View file

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

View file

@ -32,18 +32,14 @@ export default function ComparePeriodChart({
}: ComparePeriodChartProps) { }: ComparePeriodChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
if (rows.length === 0) { // The chart stays a spending view: the income-statement result (revenues +
return ( // result lines, Issue #253) lives in the table, so keep only expense leaves
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic"> // here — mixing revenue bars into the same axis would misread. Drop subtotal
{t("reports.empty.noData")} // (is_parent) rows too so a group is not double-counted against its own
</div> // 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 && (r.category_type ?? "expense") === "expense")
// 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.
const chartData = [...rows]
.sort((a, b) => b.currentAmount - a.currentAmount) .sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({ .map((r) => ({
name: r.categoryName, name: r.categoryName,
@ -52,6 +48,16 @@ export default function ComparePeriodChart({
color: r.categoryColor, 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 previousFill = "var(--muted-foreground)";
const currentFill = "var(--primary)"; const currentFill = "var(--primary)";

View file

@ -1,5 +1,11 @@
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { CategoryDelta } from "../../shared/types"; import type { CategoryDelta } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
export interface ComparePeriodTableProps { export interface ComparePeriodTableProps {
rows: CategoryDelta[]; rows: CategoryDelta[];
@ -30,24 +36,41 @@ function formatSignedCurrency(amount: number, language: string): string {
}).format(amount); }).format(amount);
} }
function formatPct(pct: number | null, language: string): string { function formatPct(pctValue: number | null, language: string): string {
if (pct === null) return "—"; if (pctValue === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent", style: "percent",
maximumFractionDigits: 1, maximumFractionDigits: 1,
signDisplay: "always", signDisplay: "always",
}).format(pct / 100); }).format(pctValue / 100);
} }
function variationColor(value: number): string { /**
// Compare report deals with expenses (abs values): spending more is negative * Delta colour, direction-aware (Issue #253). Expenses/transfers keep the
// for the user, spending less is positive. Mirror that colour convention * spending convention (increase red, decrease green); income and the
// consistently so the eye parses the delta sign at a glance. * result lines invert it (higher is better green). Also used to colour a
if (value > 0) return "var(--negative, #ef4444)"; * result *amount* by sign: a surplus is green, a deficit red.
if (value < 0) return "var(--positive, #10b981)"; */
return ""; 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";
// Collapse groups keyed by category id; depth/parent mirror the render logic
// below so the hidden rows are exactly a group's indented descendants.
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
keyOf: (row) => String(row.categoryId),
depthOf: (row) => row.depth ?? 0,
isParent: (row) => row.is_parent ?? false,
};
const COMPARE_EXPANDED_STORAGE_KEY = "reports-compare-expanded";
const COL_COUNT = 9;
export default function ComparePeriodTable({ export default function ComparePeriodTable({
rows, rows,
previousLabel, previousLabel,
@ -56,31 +79,291 @@ export default function ComparePeriodTable({
cumulativeCurrentLabel, cumulativeCurrentLabel,
}: ComparePeriodTableProps) { }: ComparePeriodTableProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language;
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "top";
});
const toggleSubtotals = () => {
setSubtotalsOnTop((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
return next;
});
};
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
const groups = useCollapsibleGroups<CategoryDelta>(
COMPARE_EXPANDED_STORAGE_KEY,
COMPARE_COLLAPSE_ACCESSORS,
);
const monthPrevLabel = previousLabel; const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel; const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel; const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Totals across all rows (there is no parent/child structure in compare mode). // Group rows into contiguous type sections (the service already type-sorts:
const totals = rows.reduce( // income → expense → transfer).
(acc, r) => ({ const sectionLabels: Record<SectionType, string> = {
monthCurrent: acc.monthCurrent + r.currentAmount, expense: t("reports.compare.sections.expenses"),
monthPrevious: acc.monthPrevious + r.previousAmount, income: t("reports.compare.sections.income"),
monthDelta: acc.monthDelta + r.deltaAbs, transfer: t("reports.compare.sections.transfers"),
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount, };
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount, const sectionTotalKeys: Record<SectionType, string> = {
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs, expense: "reports.compare.totalExpenses",
}), income: "reports.compare.totalIncome",
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 }, transfer: "reports.compare.totalTransfers",
};
const sections: { type: SectionType; rows: CategoryDelta[] }[] = [];
let currentType: SectionType | null = null;
for (const row of rows) {
const type = (row.category_type ?? "expense") as SectionType;
if (type !== currentType) {
currentType = type;
sections.push({ type, rows: [] });
}
sections[sections.length - 1].rows.push(row);
}
// 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>
); );
const totalMonthPct = })}
totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null; {/* Section net total */}
const totalYtdPct = <tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null; <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 ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end 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"
>
<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)" }}> <div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
@ -138,111 +421,25 @@ export default function ComparePeriodTable({
<tbody> <tbody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td <td colSpan={COL_COUNT} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
colSpan={9}
className="px-3 py-4 text-center text-[var(--muted-foreground)] italic"
>
{t("reports.empty.noData")} {t("reports.empty.noData")}
</td> </td>
</tr> </tr>
) : ( ) : (
<> <>
{rows.map((row) => ( {nonTransferSections.map(renderSection)}
<tr {/* Operating result (revenues expenses), shown before the
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`} transfers section only when transfers exist otherwise it
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40" equals the net total below and would just be noise. */}
> {results.hasTransfers &&
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10"> renderResultRow(
<span className="flex items-center gap-2"> "reports.compare.resultBeforeTransfers",
<span results.resultBefore,
className="w-2.5 h-2.5 rounded-full shrink-0" false,
style={{ backgroundColor: row.categoryColor }} )}
/> {transferSection && renderSection(transferSection)}
{row.categoryName} {/* Bottom line: result after netting transfers. */}
</span> {renderResultRow("reports.compare.resultNet", results.resultNet, true)}
</td>
{/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, i18n.language)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, i18n.language)}
</td>
</tr>
))}
{/* 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, i18n.language)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatSignedCurrency(totals.monthDelta, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatPct(totalMonthPct, i18n.language)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, i18n.language)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatSignedCurrency(totals.ytdDelta, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatPct(totalYtdPct, i18n.language)}
</td>
</tr>
</> </>
)} )}
</tbody> </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 { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetYearRow, BudgetTemplate } from "../shared/types"; import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
import { import {
getAllActiveCategories, getAllActiveCategories,
getBudgetEntriesForYear, getBudgetEntriesForYear,
@ -11,11 +11,14 @@ import {
applyTemplate as applyTemplateSvc, applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc, deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService"; } from "../services/budgetService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
interface BudgetState { interface BudgetState {
year: number; year: number;
rows: BudgetYearRow[]; rows: BudgetYearRow[];
templates: BudgetTemplate[]; templates: BudgetTemplate[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
error: string | null; error: string | null;
@ -26,6 +29,7 @@ type BudgetAction =
| { type: "SET_SAVING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } } | { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_YEAR"; payload: number }; | { type: "SET_YEAR"; payload: number };
function initialState(): BudgetState { function initialState(): BudgetState {
@ -33,6 +37,7 @@ function initialState(): BudgetState {
year: new Date().getFullYear(), year: new Date().getFullYear(),
rows: [], rows: [],
templates: [], templates: [],
accounts: [],
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
error: null, error: null,
@ -54,6 +59,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
templates: action.payload.templates, templates: action.payload.templates,
isLoading: false, isLoading: false,
}; };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_YEAR": case "SET_YEAR":
return { ...state, year: action.payload }; return { ...state, year: action.payload };
default: default:
@ -61,13 +68,17 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
} }
} }
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; // Income-statement reading order: revenue first, then expenses, then
// transfers (Issue #278) — matches the compare/trend reports' ordering
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
export function useBudget() { export function useBudget() {
const { accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, undefined, initialState); const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number) => { const refreshData = useCallback(async (year: number, ids: number[]) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
@ -76,7 +87,7 @@ export function useBudget() {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([ const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1), getActualTotalsForYear(year - 1, ids),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -364,8 +375,21 @@ export function useBudget() {
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshData(state.year); refreshData(state.year, accountIds);
}, [state.year, refreshData]); }, [state.year, accountIds, refreshData]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Compare source filters — see useTrends.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
const navigateYear = useCallback((delta: -1 | 1) => { const navigateYear = useCallback((delta: -1 | 1) => {
dispatch({ type: "SET_YEAR", payload: state.year + delta }); dispatch({ type: "SET_YEAR", payload: state.year + delta });
@ -376,7 +400,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await upsertBudgetEntry(categoryId, state.year, month, amount); await upsertBudgetEntry(categoryId, state.year, month, amount);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -386,7 +410,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const splitEvenly = useCallback( const splitEvenly = useCallback(
@ -400,7 +424,7 @@ export function useBudget() {
amounts.push(m < remainder ? base + 0.01 : base); amounts.push(m < remainder ? base + 0.01 : base);
} }
await upsertBudgetEntriesForYear(categoryId, state.year, amounts); await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -410,7 +434,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const saveTemplate = useCallback( const saveTemplate = useCallback(
@ -423,7 +447,7 @@ export function useBudget() {
.filter((r) => !r.is_parent && r.months[0] !== 0) .filter((r) => !r.is_parent && r.months[0] !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.months[0] })); .map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
await saveAsTemplateSvc(name, description, entries); await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -433,7 +457,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.rows, state.year, refreshData] [state.rows, state.year, accountIds, refreshData]
); );
const applyTemplate = useCallback( const applyTemplate = useCallback(
@ -441,7 +465,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await applyTemplateSvc(templateId, state.year, month); await applyTemplateSvc(templateId, state.year, month);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -451,7 +475,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const applyTemplateAllMonths = useCallback( const applyTemplateAllMonths = useCallback(
@ -461,7 +485,7 @@ export function useBudget() {
for (let m = 1; m <= 12; m++) { for (let m = 1; m <= 12; m++) {
await applyTemplateSvc(templateId, state.year, m); await applyTemplateSvc(templateId, state.year, m);
} }
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -471,7 +495,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const deleteTemplate = useCallback( const deleteTemplate = useCallback(
@ -479,7 +503,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await deleteTemplateSvc(templateId); await deleteTemplateSvc(templateId);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -489,7 +513,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
return { return {

View file

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

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare"; import {
previousMonth,
defaultReferencePeriod,
comparisonMeta,
syncReferenceOnPeriodChange,
} from "./useCompare";
describe("useCompare helpers", () => { describe("useCompare helpers", () => {
describe("previousMonth", () => { describe("previousMonth", () => {
@ -44,4 +49,40 @@ describe("useCompare helpers", () => {
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 }); 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 { useReducer, useCallback, useEffect, useRef } from "react";
import type { CategoryDelta } from "../shared/types"; import type { CategoryDelta, ImportSource } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod"; import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
@ -13,6 +14,7 @@ interface State {
year: number; year: number;
month: number; month: number;
rows: CategoryDelta[]; rows: CategoryDelta[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -23,6 +25,7 @@ type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] } | { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
/** /**
@ -60,6 +63,32 @@ export function comparisonMeta(
return { previousYear: year - 1, previousMonth: month }; 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 defaultRef = defaultReferencePeriod();
const initialState: State = { const initialState: State = {
mode: "actual", mode: "actual",
@ -67,6 +96,7 @@ const initialState: State = {
year: defaultRef.year, year: defaultRef.year,
month: defaultRef.month, month: defaultRef.month,
rows: [], rows: [],
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -83,6 +113,8 @@ function reducer(state: State, action: Action): State {
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_ROWS": case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null }; return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -91,20 +123,26 @@ function reducer(state: State, action: Action): State {
} }
export function useCompare() { export function useCompare() {
const { from, to } = useReportsPeriod(); const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback( const fetch = useCallback(
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => { async (
mode: CompareMode,
subMode: CompareSubMode,
year: number,
month: number,
ids: number[],
) => {
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
const id = ++fetchIdRef.current; const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
const rows = const rows =
subMode === "mom" subMode === "mom"
? await getCompareMonthOverMonth(year, month) ? await getCompareMonthOverMonth(year, month, ids)
: await getCompareYearOverYear(year, month); : await getCompareYearOverYear(year, month, ids);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows }); dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) { } catch (e) {
@ -116,18 +154,37 @@ export function useCompare() {
); );
useEffect(() => { useEffect(() => {
fetch(state.mode, state.subMode, state.year, state.month); fetch(state.mode, state.subMode, state.year, state.month, accountIds);
}, [fetch, state.mode, state.subMode, state.year, state.month]); }, [fetch, state.mode, state.subMode, state.year, state.month, accountIds]);
// When the URL period changes, align the reference month with `to`. // Load the import-source list once, for the FilterPanel's account checkboxes
// The explicit dropdown remains the primary selector — this effect only // (same query as the Trends/Transactions source filters — see useTrends.ts).
// keeps the two in sync when the user navigates via PeriodSelector.
useEffect(() => { useEffect(() => {
const [y, m] = to.split("-").map(Number); (async () => {
if (!Number.isFinite(y) || !Number.isFinite(m)) return; try {
if (y !== state.year || m !== state.month) { const accounts = await getAllImportSources();
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } }); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]); }, [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 { useReducer, useCallback, useEffect, useRef } from "react";
import type { import type {
DashboardPeriod, DashboardPeriod,
DashboardSummary,
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, CategoryOverTimeData,
BudgetVsActualRow, CartesSnapshot,
ImportSource,
} from "../shared/types"; } from "../shared/types";
import { import {
getDashboardSummary,
getExpensesByCategory, getExpensesByCategory,
deriveNetWorthTile,
type NetWorthTileData,
} from "../services/dashboardService"; } from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService"; import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService"; import { getAllImportSources } from "../services/transactionService";
import {
getSnapshotTotalsByDate,
listSnapshots,
listBalanceAccounts,
} from "../services/balance.service";
import { computeDateRange } from "../utils/dateRange"; import { computeDateRange } from "../utils/dateRange";
import { defaultReferencePeriod } from "../utils/referencePeriod";
interface DashboardState { interface DashboardState {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[]; categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[]; cartesSnapshot: CartesSnapshot | null;
period: DashboardPeriod; period: DashboardPeriod;
budgetYear: number;
budgetMonth: number;
customDateFrom: string; customDateFrom: string;
customDateTo: string; customDateTo: string;
referenceYear: number;
referenceMonth: number;
accountIds: number[];
accounts: ImportSource[];
netWorth: NetWorthTileData;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -34,30 +68,46 @@ type DashboardAction =
| { | {
type: "SET_DATA"; type: "SET_DATA";
payload: { payload: {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[]; categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[]; cartesSnapshot: CartesSnapshot;
}; };
} }
| { type: "SET_PERIOD"; payload: DashboardPeriod } | { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } } | { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }; | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_ACCOUNT_IDS"; payload: number[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_NET_WORTH"; payload: NetWorthTileData };
const now = new Date(); const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const yearStartStr = `${now.getFullYear()}-01-01`; const yearStartStr = `${now.getFullYear()}-01-01`;
const defaultRef = defaultReferencePeriod();
const EMPTY_CATEGORY_OVER_TIME: CategoryOverTimeData = {
categories: [],
data: [],
colors: {},
categoryIds: {},
types: {},
tree: [],
};
const EMPTY_NET_WORTH: NetWorthTileData = { visible: false, total: null, asOfDate: null };
const initialState: DashboardState = { const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [], categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, categoryOverTime: EMPTY_CATEGORY_OVER_TIME,
budgetVsActual: [], cartesSnapshot: null,
period: "year", period: "year",
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
customDateFrom: yearStartStr, customDateFrom: yearStartStr,
customDateTo: todayStr, customDateTo: todayStr,
referenceYear: defaultRef.year,
referenceMonth: defaultRef.month,
accountIds: [],
accounts: [],
netWorth: EMPTY_NET_WORTH,
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -71,18 +121,28 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
case "SET_DATA": case "SET_DATA":
return { return {
...state, ...state,
summary: action.payload.summary,
categoryBreakdown: action.payload.categoryBreakdown, categoryBreakdown: action.payload.categoryBreakdown,
categoryOverTime: action.payload.categoryOverTime, categoryOverTime: action.payload.categoryOverTime,
budgetVsActual: action.payload.budgetVsActual, cartesSnapshot: action.payload.cartesSnapshot,
isLoading: false, isLoading: false,
}; };
case "SET_PERIOD": case "SET_PERIOD":
return { ...state, period: action.payload }; return { ...state, period: action.payload };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_CUSTOM_DATES": case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo }; return {
...state,
period: "custom" as DashboardPeriod,
customDateFrom: action.payload.dateFrom,
customDateTo: action.payload.dateTo,
};
case "SET_REFERENCE_PERIOD":
return { ...state, referenceYear: action.payload.year, referenceMonth: action.payload.month };
case "SET_ACCOUNT_IDS":
return { ...state, accountIds: action.payload };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_NET_WORTH":
return { ...state, netWorth: action.payload };
default: default:
return state; return state;
} }
@ -96,8 +156,9 @@ export function useDashboard() {
period: DashboardPeriod, period: DashboardPeriod,
customFrom: string | undefined, customFrom: string | undefined,
customTo: string | undefined, customTo: string | undefined,
bYear: number, refYear: number,
bMonth: number, refMonth: number,
accountIds: number[],
) => { ) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
@ -105,15 +166,17 @@ export function useDashboard() {
try { try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([ const [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([
getDashboardSummary(dateFrom, dateTo), getExpensesByCategory(dateFrom, dateTo, accountIds),
getExpensesByCategory(dateFrom, dateTo), // typeFilter "expense" (Issue #279): this chart is titled "expenses
getCategoryOverTime(dateFrom, dateTo), // over time" but previously aggregated every category type — a
getBudgetVsActualData(bYear, bMonth), // revenue category could silently show up in an "expenses" trend.
getCategoryOverTime(dateFrom, dateTo, undefined, accountIds, "expense"),
getCartesSnapshot(refYear, refMonth, "month", accountIds),
]); ]);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } }); dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } });
} catch (e) { } catch (e) {
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ dispatch({
@ -124,8 +187,59 @@ export function useDashboard() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth); fetchData(
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]); state.period,
state.customDateFrom,
state.customDateTo,
state.referenceYear,
state.referenceMonth,
state.accountIds,
);
}, [
state.period,
state.customDateFrom,
state.customDateTo,
state.referenceYear,
state.referenceMonth,
state.accountIds,
fetchData,
]);
// One-time: import sources for the FilterPanel's account checkboxes (same
// query as Trends/Compare/Budget — see useTrends.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
// One-time: net-worth tile (Bilan). Independent of `period`/`referenceMonth`
// and NOT scoped by `accountIds` — a different "account" concept
// (balance_accounts vs import_sources) and a different metric (net worth vs
// transaction P&L). A failure here only hides the tile (best-effort); it
// never surfaces as a page-wide error for what is a secondary widget.
useEffect(() => {
(async () => {
try {
const [accounts, snapshots, totals] = await Promise.all([
listBalanceAccounts(),
listSnapshots(),
getSnapshotTotalsByDate(),
]);
dispatch({
type: "SET_NET_WORTH",
payload: deriveNetWorthTile(accounts.length, snapshots.length > 0, totals),
});
} catch {
dispatch({ type: "SET_NET_WORTH", payload: EMPTY_NET_WORTH });
}
})();
}, []);
const setPeriod = useCallback((period: DashboardPeriod) => { const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period }); dispatch({ type: "SET_PERIOD", payload: period });
@ -135,9 +249,13 @@ export function useDashboard() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } }); dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []); }, []);
const setBudgetMonth = useCallback((year: number, month: number) => { const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } }); dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []); }, []);
return { state, setPeriod, setCustomDates, setBudgetMonth }; const setAccountIds = useCallback((accountIds: number[]) => {
dispatch({ type: "SET_ACCOUNT_IDS", payload: accountIds });
}, []);
return { state, setPeriod, setCustomDates, setReferencePeriod, setAccountIds };
} }

View file

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

View file

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

View file

@ -13,21 +13,27 @@ import { describe, it, expect, vi } from "vitest";
// db is imported transitively (hook → balance.service → db → tauri plugins). // db is imported transitively (hook → balance.service → db → tauri plugins).
// Stub it so no module-load path tries a real connection. // Stub it so no module-load path tries a real connection.
vi.mock("../services/db", () => ({ vi.mock("../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
return {
getDb,
connectToProfile: vi.fn(), connectToProfile: vi.fn(),
initializeNewProfileDb: vi.fn(), initializeNewProfileDb: vi.fn(),
})); withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { import {
reducer, reducer,
initialState, initialState,
makeEmptyHolding, makeEmptyHolding,
holdingsFromServiceHoldings, holdingsFromServiceHoldings,
holdingsFromCsvRows,
buildSimpleLines, buildSimpleLines,
buildDetailedLines, buildDetailedLines,
type HoldingDraft, type HoldingDraft,
} from "./useSnapshotEditor"; } from "./useSnapshotEditor";
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
import { BalanceServiceError } from "../services/balance.service"; import { BalanceServiceError } from "../services/balance.service";
import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types"; import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types";
@ -287,6 +293,222 @@ describe("buildDetailedLines — holdings save path (#213)", () => {
}); });
}); });
describe("holdingsFromCsvRows — CSV rows → drafts (#245)", () => {
const withPrice: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
};
it("maps rows to drafts, normalizing the symbol and parsing FR numbers", () => {
const drafts = holdingsFromCsvRows(
[
["aapl", "10", "150,25", "1 200,00"],
["msft", "5", "300.50", "1400"],
],
withPrice
);
expect(drafts).toHaveLength(2);
expect(drafts[0].symbol).toBe("AAPL"); // normalized UPPER/TRIM
expect(drafts[0].quantity).toBe("10");
expect(drafts[0].unit_price).toBe("150.25"); // FR comma decimal
expect(drafts[0].book_cost).toBe("1200"); // FR thousands space stripped
expect(drafts[1].symbol).toBe("MSFT");
expect(drafts[1].unit_price).toBe("300.5");
});
it("leaves unit_price empty when there is no price column (flexible price)", () => {
const noPrice: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: null,
book_cost: null,
};
const [d] = holdingsFromCsvRows([["AAPL", "10"]], noPrice);
expect(d.symbol).toBe("AAPL");
expect(d.quantity).toBe("10");
expect(d.unit_price).toBe(""); // → validated at save (fetch/type later)
expect(d.book_cost).toBe("");
});
it("merges duplicate symbols: SUM quantity + book_cost, keep first price", () => {
const drafts = holdingsFromCsvRows(
[
["AAPL", "6", "150.00", "700"],
["MSFT", "5", "300.00", "1400"],
["AAPL", "4", "151.00", "500"], // second AAPL lot
],
withPrice
);
// Two distinct securities → two drafts, no UNIQUE(symbol) violation.
expect(drafts).toHaveLength(2);
const aapl = drafts.find((d) => d.symbol === "AAPL")!;
expect(aapl.quantity).toBe("10"); // 6 + 4
expect(aapl.book_cost).toBe("1200"); // 700 + 500
expect(aapl.unit_price).toBe("150"); // FIRST non-empty price wins
});
it("skips rows with a blank symbol and defaults asset_type", () => {
const drafts = holdingsFromCsvRows(
[
["", "10", "1", "1"],
[" ", "10", "1", "1"],
["BTC", "0.5", "1000", "400"],
],
withPrice,
{ defaultAssetType: "crypto" }
);
expect(drafts).toHaveLength(1);
expect(drafts[0].symbol).toBe("BTC");
expect(drafts[0].asset_type).toBe("crypto");
});
});
describe("IMPORT_HOLDINGS reducer — bulk merge into a basket (#245)", () => {
const mapping: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
};
it("appends imported drafts into an empty basket", () => {
const drafts = holdingsFromCsvRows(
[
["AAPL", "10", "150", "1200"],
["MSFT", "5", "300", "1400"],
],
mapping
);
const s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: drafts },
});
expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "MSFT"]);
expect(s.isDirty).toBe(true);
});
it("updates an existing symbol in place (keeps its rowId) and appends new ones", () => {
const first = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
let s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: first },
});
const aaplRowId = s.holdings[5][0].rowId;
const second = holdingsFromCsvRows(
[
["AAPL", "12", "160", "1300"],
["GOOG", "2", "140", "260"],
],
mapping
);
s = reducer(s, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: second },
});
// No duplicate AAPL row — merged in place with a stable rowId.
expect(s.holdings[5].filter((h) => h.symbol === "AAPL")).toHaveLength(1);
const aapl = s.holdings[5].find((h) => h.symbol === "AAPL")!;
expect(aapl.rowId).toBe(aaplRowId);
expect(aapl.quantity).toBe("12"); // import wins for quantity
expect(aapl.unit_price).toBe("160");
expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "GOOG"]);
});
it("an import WITHOUT price does not wipe a previously-set price", () => {
const priced = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
let s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: priced },
});
const noPriceMapping: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: null,
book_cost: null,
};
const repriced = holdingsFromCsvRows([["AAPL", "20"]], noPriceMapping);
s = reducer(s, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: repriced },
});
const aapl = s.holdings[5][0];
expect(aapl.quantity).toBe("20"); // qty updated
expect(aapl.unit_price).toBe("150"); // price preserved
});
it("produces no duplicate symbols when the batch itself repeats a symbol", () => {
// holdingsFromCsvRows already merges within the batch; the reducer is a
// second guard. Even if fed pre-merged drafts, the basket stays unique.
const drafts = holdingsFromCsvRows(
[
["AAPL", "3", "150", "450"],
["AAPL", "7", "150", "1050"],
],
mapping
);
const s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: drafts },
});
expect(s.holdings[5]).toHaveLength(1);
expect(s.holdings[5][0].quantity).toBe("10");
});
});
describe("IMPORT_HOLDINGS → buildDetailedLines end-to-end (#245)", () => {
const mapping: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
};
it("imported priced drafts flow through buildDetailedLines to holdings", () => {
const drafts = holdingsFromCsvRows(
[
["AAPL", "10", "150.25", "1200"],
["MSFT", "5", "300.50", "1400"],
],
mapping
);
const s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: drafts },
});
const [line] = buildDetailedLines(s.holdings, new Set([5]));
expect(line.holdings).toHaveLength(2);
expect(line.holdings![0].symbol).toBe("AAPL");
expect(line.holdings![0].unit_price).toBe(150.25);
expect(line.holdings![0].book_cost).toBe(1200);
// 10 * 150.25 + 5 * 300.50 = 1502.50 + 1502.50 = 3005.
expect(line.value).toBe(3005);
});
it("imported drafts WITHOUT price fail save validation until fetched (GOTCHA)", () => {
const noPriceMapping: HoldingColumnMapping = {
symbol: 0,
quantity: 1,
unit_price: null,
book_cost: null,
};
const drafts = holdingsFromCsvRows([["AAPL", "10"]], noPriceMapping);
const s = reducer(base, {
type: "IMPORT_HOLDINGS",
payload: { accountId: 5, holdings: drafts },
});
let code: string | null = null;
try {
buildDetailedLines(s.holdings, new Set([5]));
} catch (e) {
code = (e as BalanceServiceError).code;
}
expect(code).toBe("snapshot_priced_unit_price_required");
});
});
describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => { describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => {
it("routes a detailed account through holdings even if its category is simple", () => { it("routes a detailed account through holdings even if its category is simple", () => {
// Regression target: the account's OWN kind decides the path. Account 5 is // Regression target: the account's OWN kind decides the path. Account 5 is

View file

@ -31,6 +31,7 @@ import {
useEffect, useEffect,
useRef, useRef,
} from "react"; } from "react";
import { logInfo, logError } from "../services/logService";
import type { import type {
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceAssetType, BalanceAssetType,
@ -51,10 +52,13 @@ import {
getPreviousSnapshot, getPreviousSnapshot,
getHoldingsForLatestSnapshot, getHoldingsForLatestSnapshot,
listHoldingsBySnapshotLine, listHoldingsBySnapshotLine,
normalizeSecuritySymbol,
BalanceServiceError, BalanceServiceError,
type SnapshotLineInput, type SnapshotLineInput,
type SnapshotHoldingInput, type SnapshotHoldingInput,
} from "../services/balance.service"; } from "../services/balance.service";
import { parseFrenchAmount } from "../utils/amountParser";
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
export type SnapshotEditorMode = "new" | "edit"; export type SnapshotEditorMode = "new" | "edit";
@ -151,6 +155,77 @@ export function holdingsFromServiceHoldings(
})); }));
} }
/**
* Map parsed CSV data rows into holding drafts for a detailed account (Issue
* #245 CSV import). `rows` are DATA rows only (no header); `mapping` gives the
* column indices from `analyzeHoldingsCsv`. Behavior:
* - Symbols are normalized (UPPER/TRIM) like manual entry (SecurityPicker) so
* an imported title collapses onto the same `balance_securities` row.
* - Numbers are parsed with `parseFrenchAmount` (handles `1 234,56`, `1,234.56`).
* - unit_price + book_cost are OPTIONAL: when the mapping's column is null (no
* price/cost column) the field stays empty; the user fetches/types it later.
* - Duplicate symbols WITHIN the CSV are merged into one draft to respect the
* UNIQUE(snapshot_line_id, security_id) constraint: quantities and book_costs
* are SUMMED (multiple lots of the same title) and the FIRST non-empty price
* is kept (the current market price is shared across lots).
* Rows with a blank symbol are skipped. Exported for unit tests.
*/
export function holdingsFromCsvRows(
rows: string[][],
mapping: HoldingColumnMapping,
opts: { defaultAssetType?: BalanceAssetType } = {}
): HoldingDraft[] {
const defaultAssetType = opts.defaultAssetType ?? "stock";
const order: string[] = [];
const bySymbol = new Map<
string,
{ symbol: string; qty: number; book: number | null; price: string }
>();
for (const row of rows) {
const rawSymbol = (row[mapping.symbol] ?? "").trim();
if (!rawSymbol) continue;
const symbol = normalizeSecuritySymbol(rawSymbol);
if (!symbol) continue;
const qtyParsed = parseFrenchAmount((row[mapping.quantity] ?? "").trim());
const qty = isNaN(qtyParsed) ? 0 : qtyParsed;
let price = "";
if (mapping.unit_price !== null) {
const p = parseFrenchAmount((row[mapping.unit_price] ?? "").trim());
if (!isNaN(p)) price = String(p);
}
let book: number | null = null;
if (mapping.book_cost !== null) {
const b = parseFrenchAmount((row[mapping.book_cost] ?? "").trim());
if (!isNaN(b)) book = b;
}
const existing = bySymbol.get(symbol);
if (existing) {
existing.qty += qty;
if (book !== null) existing.book = (existing.book ?? 0) + book;
if (!existing.price && price) existing.price = price; // first non-empty
} else {
order.push(symbol);
bySymbol.set(symbol, { symbol, qty, book, price });
}
}
return order.map((sym) => {
const a = bySymbol.get(sym)!;
return {
...makeEmptyHolding(defaultAssetType),
symbol: a.symbol,
quantity: String(a.qty),
unit_price: a.price,
book_cost: a.book !== null ? String(a.book) : "",
};
});
}
interface State { interface State {
mode: SnapshotEditorMode; mode: SnapshotEditorMode;
/** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */ /** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */
@ -211,6 +286,14 @@ type Action =
type: "ADD_HOLDING"; type: "ADD_HOLDING";
payload: { accountId: number; holding: HoldingDraft }; payload: { accountId: number; holding: HoldingDraft };
} }
| {
// Bulk-merge a batch of imported drafts into a detailed account's basket
// (Issue #245 — CSV import). Merges by normalized symbol so the batch
// never introduces a duplicate that would violate
// UNIQUE(snapshot_line_id, security_id) at save.
type: "IMPORT_HOLDINGS";
payload: { accountId: number; holdings: HoldingDraft[] };
}
| { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } } | { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } }
| { | {
type: "SET_HOLDING_FIELD"; type: "SET_HOLDING_FIELD";
@ -324,6 +407,56 @@ export function reducer(state: State, action: Action): State {
isDirty: true, isDirty: true,
}; };
} }
case "IMPORT_HOLDINGS": {
// Merge the imported batch into the existing basket by NORMALIZED symbol.
// A symbol already present is UPDATED in place (keeping its rowId for a
// stable React key); a new symbol is appended. This guarantees the basket
// never holds two rows for the same security (UNIQUE constraint at save).
const existing = state.holdings[action.payload.accountId] ?? [];
const result = existing.slice();
const indexBySymbol = new Map<string, number>();
result.forEach((h, i) => {
const key = normalizeSecuritySymbol(h.symbol);
if (key) indexBySymbol.set(key, i);
});
for (const draft of action.payload.holdings) {
const key = normalizeSecuritySymbol(draft.symbol);
if (!key) continue; // imported rows always carry a symbol; guard anyway
const idx = indexBySymbol.get(key);
if (idx !== undefined) {
const prev = result[idx];
result[idx] = {
...prev,
symbol: draft.symbol,
// Keep the existing asset_type: the CSV carries none (the draft only
// has the account's default class), while the current row's class
// was set deliberately via the picker or a prior entry.
security_name: draft.security_name || prev.security_name,
quantity: draft.quantity,
// Import wins for price/book_cost ONLY when it carries a value, so an
// import-without-price never wipes a previously fetched/typed price.
unit_price: draft.unit_price !== "" ? draft.unit_price : prev.unit_price,
book_cost: draft.book_cost !== "" ? draft.book_cost : prev.book_cost,
// A fresh imported price is a manual value → drop stale attribution.
price_source:
draft.unit_price !== "" ? null : prev.price_source,
price_fetched_at:
draft.unit_price !== "" ? null : prev.price_fetched_at,
};
} else {
indexBySymbol.set(key, result.length);
result.push(draft);
}
}
return {
...state,
holdings: {
...state.holdings,
[action.payload.accountId]: result,
},
isDirty: true,
};
}
case "REMOVE_HOLDING": { case "REMOVE_HOLDING": {
const existing = state.holdings[action.payload.accountId] ?? []; const existing = state.holdings[action.payload.accountId] ?? [];
return { return {
@ -664,6 +797,35 @@ export function useSnapshotEditor(options: Options = {}) {
[] []
); );
/**
* Import a batch of holdings from parsed CSV rows into a detailed account
* (Issue #245). Builds drafts via `holdingsFromCsvRows` (FR numbers, optional
* price, duplicate-symbol merge) then dispatches IMPORT_HOLDINGS, which merges
* them into the account's basket by symbol. New securities default to the
* account's category asset class (else 'stock').
*/
const importHoldings = useCallback(
(accountId: number, rows: string[][], mapping: HoldingColumnMapping) => {
const acc = state.accounts.find((a) => a.id === accountId);
const drafts = holdingsFromCsvRows(rows, mapping, {
defaultAssetType: acc?.category_asset_type ?? "stock",
});
if (drafts.length === 0) {
logInfo("Balance: CSV import produced no holdings (no valid rows)");
return 0;
}
dispatch({
type: "IMPORT_HOLDINGS",
payload: { accountId, holdings: drafts },
});
logInfo(
`Balance: imported ${drafts.length} holding(s) from CSV into account ${accountId}`
);
return drafts.length;
},
[state.accounts]
);
const removeHolding = useCallback((accountId: number, rowId: string) => { const removeHolding = useCallback((accountId: number, rowId: string) => {
dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } }); dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } });
}, []); }, []);
@ -798,10 +960,16 @@ export function useSnapshotEditor(options: Options = {}) {
moveToDate, moveToDate,
}); });
dispatch({ type: "CLEAR_DIRTY" }); dispatch({ type: "CLEAR_DIRTY" });
logInfo(
`Balance: snapshot saved (${state.snapshotDate}, ${
simpleLines.length + detailedLines.length
} lines)`
);
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state. // Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
await loadForDate(state.snapshotDate); await loadForDate(state.snapshotDate);
return { snapshotId }; return { snapshotId };
} catch (e) { } catch (e) {
logError(`Balance: snapshot save failed (${state.snapshotDate})`, e);
dispatch({ type: "SET_ERROR", payload: describeError(e) }); dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e; throw e;
} finally { } finally {
@ -836,6 +1004,7 @@ export function useSnapshotEditor(options: Options = {}) {
setDate, setDate,
setLineValue, setLineValue,
addHolding, addHolding,
importHoldings,
removeHolding, removeHolding,
setHoldingField, setHoldingField,
setHoldingSecurity, setHoldingSecurity,

View file

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

View file

@ -20,7 +20,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"balance": "Balance", "kpiSectionTitle": "Key indicators",
"income": "Income", "income": "Income",
"expenses": "Expenses", "expenses": "Expenses",
"net": "Net", "net": "Net",
@ -50,10 +50,11 @@
"help": { "help": {
"title": "How to use the Dashboard", "title": "How to use the Dashboard",
"tips": [ "tips": [
"Use the period selector (top right) to view different time ranges", "The filter bar at the top picks the date range and which import sources to include",
"Summary cards show your balance, income, and expenses for the selected period", "The key indicators (income, expenses, net, savings rate) cover the chosen reference month, with their change vs the previous month and vs last year",
"The pie chart breaks down your expenses by category", "The \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source",
"Recent transactions are listed at the bottom" "Rising/falling categories and budget adherence summarize the reference month's changes",
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
] ]
} }
}, },
@ -379,6 +380,10 @@
"budgetVsActual": "Budget vs Actual", "budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top", "subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom", "subtotalsOnBottom": "Subtotals on bottom",
"collapse": {
"expandAll": "Expand all",
"collapseAll": "Collapse all"
},
"detail": { "detail": {
"showAmounts": "Show amounts", "showAmounts": "Show amounts",
"hideAmounts": "Hide amounts" "hideAmounts": "Hide amounts"
@ -388,15 +393,18 @@
"search": "Search...", "search": "Search...",
"all": "All", "all": "All",
"none": "None", "none": "None",
"allTypes": "All types" "allTypes": "All types",
"accounts": {
"label": "Import sources",
"hint": "No selection = all sources"
}
}, },
"bva": { "bva": {
"monthly": "Monthly", "monthly": "Monthly",
"ytd": "Year-to-Date", "ytd": "Year-to-Date",
"dollarVar": "$ Var", "dollarVar": "$ Var",
"pctVar": "% Var", "pctVar": "% Var",
"noData": "No budget or transaction data for this period.", "noData": "No budget or transaction data for this period."
"titlePrefix": "Budget vs Actual for"
}, },
"export": "Export", "export": "Export",
"month": "Month", "month": "Month",
@ -427,7 +435,16 @@
"referenceMonth": "Reference month", "referenceMonth": "Reference month",
"currentAmount": "Current", "currentAmount": "Current",
"previousAmount": "Previous", "previousAmount": "Previous",
"totalRow": "Total" "sections": {
"expenses": "Expenses",
"income": "Income",
"transfers": "Transfers"
},
"totalExpenses": "Total Expenses",
"totalIncome": "Total Income",
"totalTransfers": "Total Transfers",
"resultBeforeTransfers": "Result before transfers",
"resultNet": "Net result"
}, },
"cartes": { "cartes": {
"kpiSectionAria": "Key indicators for the reference month", "kpiSectionAria": "Key indicators for the reference month",
@ -1435,6 +1452,7 @@
"loadError": "Failed to load profile data: {{error}}", "loadError": "Failed to load profile data: {{error}}",
"needsReview": "Needs review", "needsReview": "Needs review",
"chooseTarget": "Choose a target...", "chooseTarget": "Choose a target...",
"editTargetAria": "Change the target for {{category}}",
"txCount_one": "{{count}} transaction", "txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions", "txCount_other": "{{count}} transactions",
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.", "unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
@ -1579,20 +1597,26 @@
"byVehicle": "By envelope" "byVehicle": "By envelope"
} }
}, },
"onboarding": { "hub": {
"title": "Get started with your balance sheet", "getStarted": "Get started",
"subtitle": "Two steps to start tracking your net worth.", "manage": "Manage",
"doneBadge": "Done", "accounts": {
"step1": { "title": "Manage accounts",
"title": "Create an account", "description": "Create, edit, or archive your accounts and asset types."
"description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.",
"cta": "Create an account"
}, },
"step2": { "snapshot": {
"title": "Enter a snapshot", "title": "New snapshot",
"description": "A snapshot is the picture, at a given date, of the balance in each account. Enter one a month to track changes over time.", "description": "Record the balance of each account at a given date."
"cta": "Enter a snapshot", }
"disabledHint": "Create an account first to unlock this step." },
"landing": {
"empty": {
"title": "Track your net worth",
"subtitle": "Start by creating an account: chequing, TFSA, RRSP, stocks, crypto, and so on."
},
"noSnapshot": {
"title": "Take your first snapshot",
"subtitle": "Your accounts are ready. Enter a snapshot to start tracking your net worth over time — or keep managing your accounts."
} }
}, },
"starters": { "starters": {
@ -1795,6 +1819,25 @@
"stock": "Stock", "stock": "Stock",
"crypto": "Crypto" "crypto": "Crypto"
} }
},
"importCsv": {
"button": "Import CSV",
"title": "Import securities from a CSV",
"intro": "Import the positions of \"{{account}}\" from a CSV file (broker statement, export). Columns are detected automatically; adjust them if needed.",
"selectFile": "Choose a CSV file",
"loading": "Reading file…",
"hint": "Accepted formats: .csv, .txt. Delimiter and encoding are detected automatically.",
"detectError": "Could not detect columns in this file. Make sure it is a valid securities CSV.",
"readError": "Could not read the file.",
"retry": "Retry",
"symbolColumn": "Symbol column",
"quantityColumn": "Quantity column",
"priceColumn": "Price column (optional)",
"bookCostColumn": "Book cost column (optional)",
"noColumn": "— None —",
"noPriceNote": "No price column: securities will be imported without a price. You can fetch or type it afterwards.",
"summary": "{{count}} security(ies) will be imported.",
"import": "Import"
} }
}, },
"delete": { "delete": {

View file

@ -20,7 +20,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
"balance": "Solde", "kpiSectionTitle": "Indicateurs clés",
"income": "Revenus", "income": "Revenus",
"expenses": "Dépenses", "expenses": "Dépenses",
"net": "Net", "net": "Net",
@ -50,10 +50,11 @@
"help": { "help": {
"title": "Comment utiliser le tableau de bord", "title": "Comment utiliser le tableau de bord",
"tips": [ "tips": [
"Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates", "Le filtre en haut choisit la plage de dates et les sources d'import à inclure",
"Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée", "Les indicateurs clés (revenus, dépenses, net, taux d'épargne) portent sur le mois de référence choisi, avec leur variation vs le mois précédent et vs l'an dernier",
"Le graphique circulaire détaille vos dépenses par catégorie", "La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import",
"Les transactions récentes sont listées en bas de page" "Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
] ]
} }
}, },
@ -379,6 +380,10 @@
"budgetVsActual": "Budget vs Réel", "budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut", "subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas", "subtotalsOnBottom": "Sous-totaux en bas",
"collapse": {
"expandAll": "Tout déplier",
"collapseAll": "Tout replier"
},
"detail": { "detail": {
"showAmounts": "Afficher les montants", "showAmounts": "Afficher les montants",
"hideAmounts": "Masquer les montants" "hideAmounts": "Masquer les montants"
@ -388,15 +393,18 @@
"search": "Rechercher...", "search": "Rechercher...",
"all": "Toutes", "all": "Toutes",
"none": "Aucune", "none": "Aucune",
"allTypes": "Tous les types" "allTypes": "Tous les types",
"accounts": {
"label": "Sources d'import",
"hint": "Aucune sélection = toutes les sources"
}
}, },
"bva": { "bva": {
"monthly": "Mensuel", "monthly": "Mensuel",
"ytd": "Cumul annuel", "ytd": "Cumul annuel",
"dollarVar": "$ Écart", "dollarVar": "$ Écart",
"pctVar": "% Écart", "pctVar": "% Écart",
"noData": "Aucune donnée de budget ou de transaction pour cette période.", "noData": "Aucune donnée de budget ou de transaction pour cette période."
"titlePrefix": "Budget vs Réel pour le mois de"
}, },
"export": "Exporter", "export": "Exporter",
"month": "Mois", "month": "Mois",
@ -427,7 +435,16 @@
"referenceMonth": "Mois de référence", "referenceMonth": "Mois de référence",
"currentAmount": "Courant", "currentAmount": "Courant",
"previousAmount": "Précédent", "previousAmount": "Précédent",
"totalRow": "Total" "sections": {
"expenses": "Dépenses",
"income": "Revenus",
"transfers": "Transferts"
},
"totalExpenses": "Total des dépenses",
"totalIncome": "Total des revenus",
"totalTransfers": "Total des transferts",
"resultBeforeTransfers": "Résultat avant transferts",
"resultNet": "Résultat net"
}, },
"cartes": { "cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence", "kpiSectionAria": "Indicateurs clés du mois de référence",
@ -1435,6 +1452,7 @@
"loadError": "Impossible de charger les données du profil : {{error}}", "loadError": "Impossible de charger les données du profil : {{error}}",
"needsReview": "À réviser", "needsReview": "À réviser",
"chooseTarget": "Choisir une cible...", "chooseTarget": "Choisir une cible...",
"editTargetAria": "Modifier la cible pour {{category}}",
"txCount_one": "{{count}} transaction", "txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions", "txCount_other": "{{count}} transactions",
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.", "unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
@ -1579,20 +1597,26 @@
"byVehicle": "Par enveloppe" "byVehicle": "Par enveloppe"
} }
}, },
"onboarding": { "hub": {
"title": "Premiers pas avec le bilan", "getStarted": "Commencer",
"subtitle": "Deux étapes pour commencer à suivre votre valeur nette.", "manage": "Gérer",
"doneBadge": "Fait", "accounts": {
"step1": { "title": "Gérer les comptes",
"title": "Créer un compte", "description": "Créez, modifiez ou archivez vos comptes et vos types d'actif."
"description": "Un compte représente l'endroit où vous tenez votre argent : compte chèque, CELI, REER, actions, crypto, etc.",
"cta": "Créer un compte"
}, },
"step2": { "snapshot": {
"title": "Saisir un snapshot", "title": "Nouveau snapshot",
"description": "Un snapshot est la photo, à une date donnée, du solde de chaque compte. Saisissez-en un par mois pour suivre l'évolution.", "description": "Enregistrez le solde de chaque compte à une date donnée."
"cta": "Saisir un snapshot", }
"disabledHint": "Créez d'abord un compte pour activer cette étape." },
"landing": {
"empty": {
"title": "Suivez votre valeur nette",
"subtitle": "Commencez par créer un compte : compte chèque, CELI, REER, actions, crypto, etc."
},
"noSnapshot": {
"title": "Saisissez votre premier snapshot",
"subtitle": "Vos comptes sont prêts. Saisissez un snapshot pour suivre l'évolution de votre valeur nette dans le temps — ou continuez à gérer vos comptes."
} }
}, },
"starters": { "starters": {
@ -1795,6 +1819,25 @@
"stock": "Action", "stock": "Action",
"crypto": "Crypto" "crypto": "Crypto"
} }
},
"importCsv": {
"button": "Importer un CSV",
"title": "Importer des titres depuis un CSV",
"intro": "Importez les positions de « {{account}} » depuis un fichier CSV (relevé de courtier, exportation). Les colonnes sont détectées automatiquement ; ajustez-les au besoin.",
"selectFile": "Choisir un fichier CSV",
"loading": "Lecture du fichier…",
"hint": "Formats acceptés : .csv, .txt. Le séparateur et l'encodage sont détectés automatiquement.",
"detectError": "Impossible de détecter les colonnes dans ce fichier. Vérifiez qu'il s'agit d'un CSV de titres valide.",
"readError": "Impossible de lire le fichier.",
"retry": "Réessayer",
"symbolColumn": "Colonne du symbole",
"quantityColumn": "Colonne de la quantité",
"priceColumn": "Colonne du cours (facultatif)",
"bookCostColumn": "Colonne du coût d'acquisition (facultatif)",
"noColumn": "— Aucune —",
"noPriceNote": "Aucune colonne de cours : les titres seront importés sans prix. Vous pourrez le récupérer ou le saisir ensuite.",
"summary": "{{count}} titre(s) seront importé(s).",
"import": "Importer"
} }
}, },
"delete": { "delete": {

View file

@ -13,7 +13,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wallet } from "lucide-react"; import { Wallet, FilePlus } from "lucide-react";
import { import {
useBalanceOverview, useBalanceOverview,
type BalancePeriod, type BalancePeriod,
@ -30,12 +30,15 @@ import {
import { getAllCategories } from "../services/transactionService"; import { getAllCategories } from "../services/transactionService";
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types"; import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard"; import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard";
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
import LinkTransfersModal from "../components/balance/LinkTransfersModal"; import LinkTransfersModal from "../components/balance/LinkTransfersModal";
import DetailAccountWizard from "../components/balance/DetailAccountWizard"; import DetailAccountWizard from "../components/balance/DetailAccountWizard";
import StarterAccountsModal from "../components/balance/StarterAccountsModal"; import StarterAccountsModal from "../components/balance/StarterAccountsModal";
import HubReportNavCard, {
type HubReportNavCardProps,
} from "../components/reports/HubReportNavCard";
import { deriveLandingState } from "../components/balance/balanceLanding";
import { getPreference, setPreference } from "../services/userPreferenceService"; import { getPreference, setPreference } from "../services/userPreferenceService";
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel"; import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
@ -195,25 +198,88 @@ export default function BalancePage() {
</div> </div>
)} )}
{/* Issue #178 empty-state guard. We probe accountsLatest for ANY {/* Issue #244 landing state. We probe accountsLatest for ANY snapshot
snapshot date so the guard is independent of the active period date (independent of the active period filter, state.period) and
filter (state.period). When empty, we render only the onboarding derive one of three states via `deriveLandingState`. The old boolean
card period selector, chart and accounts table would all show guard conflated "no account" with "accounts but no snapshot" and
empty states stacked under it (S2 from #187). */} stranded the latter on a snapshot-only onboarding card, leaving the
account-management page (/balance/accounts) unreachable. We now render
navigation tiles (HubReportNavCard, modeled on the Reports hub) so
account management is reachable in every state, and only the
"populated" state (>=1 account WITH a snapshot) renders the full
dashboard otherwise period selector, chart and accounts table would
all stack empty states (S2 from #187). */}
{(() => { {(() => {
const accountsCount = state.accountsLatest.length; const accountsCount = state.accountsLatest.length;
const hasAnySnapshot = state.accountsLatest.some( const hasAnySnapshot = state.accountsLatest.some(
(a) => a.latest_snapshot_date != null (a) => a.latest_snapshot_date != null
); );
const isEmpty = accountsCount === 0 || !hasAnySnapshot; const landingState = deriveLandingState(accountsCount, hasAnySnapshot);
if (isEmpty) { // Navigation tiles reused across states. "Manage accounts" is present
// everywhere so /balance/accounts is reachable at all times (#244); the
// snapshot tile is withheld in the empty state (nothing to snapshot).
const accountsTile: HubReportNavCardProps = {
to: "/balance/accounts",
icon: <Wallet size={24} />,
title: t("balance.hub.accounts.title"),
description: t("balance.hub.accounts.description"),
};
const snapshotTile: HubReportNavCardProps = {
to: "/balance/snapshot",
icon: <FilePlus size={24} />,
title: t("balance.hub.snapshot.title"),
description: t("balance.hub.snapshot.description"),
};
const renderHub = (
tiles: HubReportNavCardProps[],
headingKey: string
) => (
<section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t(headingKey)}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{tiles.map((card) => (
<HubReportNavCard key={card.to} {...card} />
))}
</div>
</section>
);
if (landingState === "empty") {
// No account yet. StarterAccountsModal auto-opens (one-shot, #179);
// once dismissed the "Manage accounts" tile keeps account creation
// reachable. The snapshot tile is withheld — nothing to snapshot yet.
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<BalanceOnboardingCard <div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
accountsCount={accountsCount} <h2 className="text-lg font-semibold mb-1">
snapshotsCount={hasAnySnapshot ? 1 : 0} {t("balance.landing.empty.title")}
/> </h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.landing.empty.subtitle")}
</p>
</div>
{renderHub([accountsTile], "balance.hub.getStarted")}
</div>
);
}
if (landingState === "accounts-no-snapshot") {
// Accounts exist but no snapshot. Offer BOTH paths (manage accounts
// OR enter a snapshot) without forcing the snapshot flow (#244).
return (
<div className="space-y-6">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h2 className="text-lg font-semibold mb-1">
{t("balance.landing.noSnapshot.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.landing.noSnapshot.subtitle")}
</p>
</div>
{renderHub([accountsTile, snapshotTile], "balance.hub.getStarted")}
</div> </div>
); );
} }
@ -332,6 +398,8 @@ export default function BalancePage() {
onDetailAccount={(acc) => setDetailTarget(acc)} onDetailAccount={(acc) => setDetailTarget(acc)}
/> />
</div> </div>
{renderHub([accountsTile, snapshotTile], "balance.hub.manage")}
</div> </div>
); );
})()} })()}

View file

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

View file

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

View file

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

View file

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

View file

@ -212,6 +212,7 @@ export default function SnapshotEditPage() {
onRemoveHolding={editor.removeHolding} onRemoveHolding={editor.removeHolding}
onHoldingFieldChange={editor.setHoldingField} onHoldingFieldChange={editor.setHoldingField}
onHoldingSecurityPick={editor.setHoldingSecurity} onHoldingSecurityPick={editor.setHoldingSecurity}
onImportHoldings={editor.importHoldings}
disabled={state.isSaving} disabled={state.isSaving}
snapshotDate={state.snapshotDate} snapshotDate={state.snapshotDate}
/> />

View file

@ -1,8 +1,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),

View file

@ -10,7 +10,7 @@
// + price-fetch work in Issue #142. // + price-fetch work in Issue #142.
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getDb } from "./db"; import { getDb, withTransaction } from "./db";
import { loadProfiles } from "./profileService"; import { loadProfiles } from "./profileService";
import type { import type {
AccountReturn, AccountReturn,
@ -705,7 +705,15 @@ export async function proposeStarterAccounts(
): Promise<number[]> { ): Promise<number[]> {
const wanted = STARTER_ACCOUNTS.filter((s) => selectedKeys.includes(s.key)); const wanted = STARTER_ACCOUNTS.filter((s) => selectedKeys.includes(s.key));
if (wanted.length === 0) return []; if (wanted.length === 0) return [];
const db = await getDb(); return withTransaction((db) =>
proposeStarterAccountsInTransaction(db, wanted)
);
}
async function proposeStarterAccountsInTransaction(
db: SqlExecutor,
wanted: StarterDef[]
): Promise<number[]> {
let inTxn = false; let inTxn = false;
const inserted: number[] = []; const inserted: number[] = [];
try { try {
@ -1496,7 +1504,16 @@ export async function upsertSnapshotLines(
// through validateDetailedSnapshot, scalar lines through the unchanged pass). // through validateDetailedSnapshot, scalar lines through the unchanged pass).
validateAllLines(lines); validateAllLines(lines);
const db = await getDb(); return withTransaction((db) =>
upsertSnapshotLinesInTransaction(db, snapshotId, lines)
);
}
async function upsertSnapshotLinesInTransaction(
db: SqlExecutor,
snapshotId: number,
lines: SnapshotLineInput[]
): Promise<void> {
// Strategy: clear and rewrite, wrapped in an explicit transaction so the // Strategy: clear and rewrite, wrapped in an explicit transaction so the
// line + holdings writes commit together. Snapshot lines are small (one per // line + holdings writes commit together. Snapshot lines are small (one per
// active account, typically < 20). CASCADE on snapshot_line_id wipes the old // active account, typically < 20). CASCADE on snapshot_line_id wipes the old
@ -1575,7 +1592,19 @@ export async function saveSnapshotAtomic(input: {
// lines keep the unchanged validateLineKindInvariants pass. // lines keep the unchanged validateLineKindInvariants pass.
validateAllLines(input.lines); validateAllLines(input.lines);
const db = await getDb(); return withTransaction((db) => saveSnapshotAtomicInTransaction(db, input));
}
async function saveSnapshotAtomicInTransaction(
db: SqlExecutor,
input: {
existingSnapshotId: number | null;
snapshot_date: string;
notes?: string | null;
lines: SnapshotLineInput[];
moveToDate?: string | null;
}
): Promise<{ snapshotId: number }> {
let inTxn = false; let inTxn = false;
try { try {
await db.execute("BEGIN"); await db.execute("BEGIN");

View file

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

View file

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

View file

@ -6,9 +6,17 @@ import {
KEYWORD_MAX_LENGTH, KEYWORD_MAX_LENGTH,
} from "./categorizationService"; } from "./categorizationService";
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db"; import { getDb } from "./db";

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb, withTransaction } from "./db";
import type Database from "@tauri-apps/plugin-sql";
import type { Keyword, RecentTransaction } from "../shared/types"; import type { Keyword, RecentTransaction } from "../shared/types";
/** /**
@ -222,7 +223,16 @@ export async function applyKeywordWithReassignment(
} }
const keyword = validation.value; const keyword = validation.value;
const db = await getDb(); return withTransaction((db) =>
applyKeywordInTransaction(db, input, keyword),
);
}
async function applyKeywordInTransaction(
db: Database,
input: ApplyKeywordInput,
keyword: string,
): Promise<ApplyKeywordResult> {
await db.execute("BEGIN"); await db.execute("BEGIN");
try { try {
// Is there already a row for this keyword spelling? // Is there already a row for this keyword spelling?

View file

@ -3,9 +3,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// `getDb` is stubbed with a fake DB that captures every SQL statement issued // `getDb` is stubbed with a fake DB that captures every SQL statement issued
// during applyMigration. This is a unit test — we do NOT run a real SQLite. // during applyMigration. This is a unit test — we do NOT run a real SQLite.
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
// `setPreference` goes through the same fake DB — no extra stubbing needed // `setPreference` goes through the same fake DB — no extra stubbing needed
// because userPreferenceService itself calls getDb(). // because userPreferenceService itself calls getDb().

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { withTransaction } from "./db";
import type Database from "@tauri-apps/plugin-sql";
import { setPreference } from "./userPreferenceService"; import { setPreference } from "./userPreferenceService";
import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService"; import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
import type { BackupResult } from "./categoryBackupService"; import type { BackupResult } from "./categoryBackupService";
@ -178,7 +179,17 @@ export async function applyMigration(
return outcome; return outcome;
} }
const db = await getDb(); return withTransaction((db) =>
applyMigrationInTransaction(db, plan, backup, outcome),
);
}
async function applyMigrationInTransaction(
db: Database,
plan: MigrationPlan,
backup: BackupResult,
outcome: MigrationOutcome,
): Promise<MigrationOutcome> {
const mapping = buildMappingFromRows(plan.rows); const mapping = buildMappingFromRows(plan.rows);
await db.execute("BEGIN"); await db.execute("BEGIN");

View file

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

View file

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

123
src/services/db.test.ts Normal file
View file

@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Regression coverage for the "database is locked" fix (#228 follow-up).
//
// tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool
// and exposes no transaction primitive, so a BEGIN and its COMMIT issued as
// separate db.execute calls could land on different pooled connections and
// strand an open write transaction (= permanent lock). db.ts funnels every
// operation through one FIFO lock, and withTransaction holds it for the whole
// BEGIN..COMMIT. These tests assert that serialization, without a real SQLite.
// Ordered log of every fake-db operation's start/end, shared with the fake db.
const events: string[] = [];
// Hoisted handle so the (hoisted) vi.mock factory can reach the per-test db.
const h = vi.hoisted(() => ({ fakeDb: null as unknown as FakeDb }));
interface FakeDb {
execute: (q: string, p?: unknown[]) => Promise<{ rowsAffected: number }>;
select: (q: string, p?: unknown[]) => Promise<unknown[]>;
close: () => Promise<void>;
}
vi.mock("@tauri-apps/plugin-sql", () => ({
default: { load: vi.fn(async () => h.fakeDb) },
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => false) }));
import { connectToProfile, getDb, withTransaction } from "./db";
// Each op records start, yields a real macrotask (a window for interleaving if
// the lock were broken), then records end. The lock must keep them ordered.
function makeFakeDb(): FakeDb {
return {
execute: vi.fn(async (q: string) => {
events.push(`start:${q}`);
await new Promise((r) => setTimeout(r, 2));
events.push(`end:${q}`);
return { rowsAffected: 0 };
}),
select: vi.fn(async (q: string) => {
events.push(`start:${q}`);
await new Promise((r) => setTimeout(r, 2));
events.push(`end:${q}`);
return [];
}),
close: vi.fn(async () => {}),
};
}
beforeEach(async () => {
events.length = 0;
h.fakeDb = makeFakeDb();
await connectToProfile("test.db");
});
describe("db serialization (database-locked fix)", () => {
it("serializes concurrent execute calls — no interleaving", async () => {
const db = await getDb();
// Fire both without awaiting between them: they race for the lock.
await Promise.all([db.execute("A"), db.execute("B")]);
// A must fully complete before B starts (FIFO, no overlap).
expect(events).toEqual(["start:A", "end:A", "start:B", "end:B"]);
});
it("holds the lock across a whole withTransaction — an outside read cannot interleave", async () => {
const db = await getDb();
// Queue the transaction first, then an outside read.
const txn = withTransaction(async (tx) => {
await tx.execute("BEGIN");
await tx.execute("WRITE");
await tx.execute("COMMIT");
});
const outside = db.select("READ");
await Promise.all([txn, outside]);
// The outside read runs entirely AFTER the transaction's COMMIT — the
// pre-fix bug was exactly an outside read interleaving mid-transaction.
expect(events).toEqual([
"start:BEGIN",
"end:BEGIN",
"start:WRITE",
"end:WRITE",
"start:COMMIT",
"end:COMMIT",
"start:READ",
"end:READ",
]);
});
it("lets a nested getDb() call run directly inside a transaction — no deadlock", async () => {
// A service called inside withTransaction might reach for getDb() (the
// serialized proxy) instead of the threaded handle. The reentrancy guard
// must run it directly rather than queue it behind the held lock (deadlock).
const result = await withTransaction(async () => {
const inner = await getDb();
await inner.execute("NESTED");
return "ok";
});
expect(result).toBe("ok");
expect(events).toEqual(["start:NESTED", "end:NESTED"]);
});
it("releases the lock after a failed transaction so later ops still run", async () => {
const db = await getDb();
await expect(
withTransaction(async (tx) => {
await tx.execute("BEGIN");
throw new Error("boom");
})
).rejects.toThrow("boom");
// The lock must be free again: a subsequent op completes (no stuck queue).
await db.execute("AFTER");
expect(events).toContain("start:AFTER");
expect(events).toContain("end:AFTER");
});
it("withTransaction rejects when no profile is connected", async () => {
const { closeDb } = await import("./db");
await closeDb();
await expect(withTransaction(async () => undefined)).rejects.toThrow(
/No database connection/
);
});
});

View file

@ -1,19 +1,113 @@
import Database from "@tauri-apps/plugin-sql"; import Database from "@tauri-apps/plugin-sql";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
let dbInstance: Database | null = null; let rawInstance: Database | null = null;
let serializedInstance: Database | null = null;
// ---------------------------------------------------------------------------
// DB access serialization (#228 follow-up — fix "database is locked").
//
// tauri-plugin-sql exposes NO transaction primitive to the JS side and loads
// SQLite through a default multi-connection sqlx pool (`Pool::connect` =>
// max_connections = 10). Every db.execute / db.select is an independent
// `pool.acquire()`, so a `BEGIN` issued on one pooled connection and its
// matching `COMMIT` issued on another would strand an open write transaction
// on an idle connection — that connection then holds SQLite's write lock until
// the app restarts, and every later write fails with "database is locked".
//
// Fix: funnel EVERY db operation through a single FIFO async lock. Plain
// reads/writes take the lock per-statement; `withTransaction` holds it across
// the whole BEGIN..COMMIT. With no two operations ever overlapping in time,
// the pool keeps handing back its single idle connection, so a transaction's
// statements all run on the same connection and can never strand. Serializing
// all DB access is a non-issue for a local single-user app where queries are
// tiny and infrequent — and it makes the "atomic" saves genuinely atomic.
// ---------------------------------------------------------------------------
let lockTail: Promise<unknown> = Promise.resolve();
// True only while a `withTransaction` callback is executing. The lock is held
// for that whole callback, so anything it triggers (the threaded handle, or an
// accidental `getDb()` call from a nested service) must run DIRECTLY rather
// than re-entering the non-reentrant lock — otherwise it would deadlock behind
// the transaction that is waiting for it.
let inTransaction = false;
/** Run `fn` after every previously-queued DB operation has settled (FIFO). */
function runExclusive<T>(fn: () => Promise<T>): Promise<T> {
const result = lockTail.then(fn, fn);
// Keep the chain alive regardless of fn's outcome; only the tail swallows.
lockTail = result.then(
() => undefined,
() => undefined
);
return result;
}
/**
* Wrap the raw Database so `execute`/`select` are serialized through the lock.
* While a transaction holds the lock (`inTransaction`), calls run directly so a
* nested call never deadlocks. The proxy keeps the `Database` type, so call
* sites (including `db.select<T>(...)`) are unchanged.
*/
function serialize(db: Database): Database {
return new Proxy(db, {
get(target, prop, receiver) {
if (prop === "execute") {
return (query: string, bindValues?: unknown[]) =>
inTransaction
? target.execute(query, bindValues)
: runExclusive(() => target.execute(query, bindValues));
}
if (prop === "select") {
return (query: string, bindValues?: unknown[]) =>
inTransaction
? target.select(query, bindValues)
: runExclusive(() => target.select(query, bindValues));
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
}
export async function getDb(): Promise<Database> { export async function getDb(): Promise<Database> {
if (!dbInstance) { if (!serializedInstance) {
throw new Error("No database connection. Call connectToProfile() first."); throw new Error("No database connection. Call connectToProfile() first.");
} }
return dbInstance; return serializedInstance;
}
/**
* Run `fn` as one serialized transaction. The global DB lock is held for the
* ENTIRE callback so no other db.execute/select can interleave and strand the
* transaction on a different pooled connection. `fn` receives the db handle and
* is responsible for issuing BEGIN/COMMIT (and ROLLBACK on error), exactly as
* the inline pattern did before `withTransaction` only guarantees exclusive,
* non-interleaved access for the whole BEGIN..COMMIT.
*/
export async function withTransaction<T>(
fn: (db: Database) => Promise<T>
): Promise<T> {
if (!serializedInstance) {
throw new Error("No database connection. Call connectToProfile() first.");
}
const db = serializedInstance;
return runExclusive(async () => {
inTransaction = true;
try {
return await fn(db);
} finally {
inTransaction = false;
}
});
} }
export async function connectToProfile(dbFilename: string): Promise<void> { export async function connectToProfile(dbFilename: string): Promise<void> {
if (dbInstance) { if (rawInstance) {
await dbInstance.close(); await rawInstance.close();
dbInstance = null; rawInstance = null;
serializedInstance = null;
} }
// Repair migration checksums before loading (fixes "migration was modified" error) // Repair migration checksums before loading (fixes "migration was modified" error)
try { try {
@ -24,23 +118,27 @@ export async function connectToProfile(dbFilename: string): Promise<void> {
} catch (e) { } catch (e) {
console.error("Migration repair failed:", e); console.error("Migration repair failed:", e);
} }
dbInstance = await Database.load(`sqlite:${dbFilename}`); rawInstance = await Database.load(`sqlite:${dbFilename}`);
serializedInstance = serialize(rawInstance);
} }
export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> { export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
if (dbInstance) { if (rawInstance) {
await dbInstance.close(); await rawInstance.close();
dbInstance = null; rawInstance = null;
serializedInstance = null;
} }
dbInstance = await Database.load(`sqlite:${dbFilename}`); rawInstance = await Database.load(`sqlite:${dbFilename}`);
serializedInstance = serialize(rawInstance);
for (const sql of sqlStatements) { for (const sql of sqlStatements) {
await dbInstance.execute(sql); await rawInstance.execute(sql);
} }
} }
export async function closeDb(): Promise<void> { export async function closeDb(): Promise<void> {
if (dbInstance) { if (rawInstance) {
await dbInstance.close(); await rawInstance.close();
dbInstance = null; rawInstance = null;
serializedInstance = null;
} }
} }

View file

@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import { getRecentErrorLogs, clearLogs, initLogCapture } from "./logService"; import {
getRecentErrorLogs,
getLogs,
clearLogs,
initLogCapture,
} from "./logService";
beforeAll(() => { beforeAll(() => {
// Patch console.* so addEntry runs. Idempotent. // Patch console.* so addEntry runs. Idempotent.
@ -54,3 +59,39 @@ describe("getRecentErrorLogs", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
// Regression: the log console (Settings → Systems → Journaux) stayed empty
// because getLogs returned the same mutated array reference on every call, so
// useSyncExternalStore (which compares snapshots by identity) never re-rendered
// when a new log arrived. getLogs must return a STABLE reference between
// mutations (no per-call re-slice, or React loops) and a NEW one after each.
describe("getLogs — live-update snapshot", () => {
beforeEach(() => {
clearLogs();
});
it("returns a stable reference between mutations", () => {
expect(getLogs()).toBe(getLogs());
console.warn("x");
const snap = getLogs();
expect(snap).toBe(getLogs());
});
it("returns a NEW reference after a log so the store re-renders", () => {
const before = getLogs();
console.warn("new entry");
const after = getLogs();
expect(after).not.toBe(before);
expect(after).toHaveLength(before.length + 1);
expect(after[after.length - 1].message).toBe("new entry");
});
it("returns a NEW reference after clearLogs", () => {
console.warn("y");
const before = getLogs();
clearLogs();
const after = getLogs();
expect(after).not.toBe(before);
expect(after).toHaveLength(0);
});
});

View file

@ -13,6 +13,18 @@ const STORAGE_KEY = "simpl-resultat-logs";
const logs: LogEntry[] = []; const logs: LogEntry[] = [];
const listeners = new Set<LogListener>(); const listeners = new Set<LogListener>();
// Immutable view of `logs`, rebuilt on every mutation. `useSyncExternalStore`
// compares snapshots by identity (Object.is), so `getLogs` MUST return a NEW
// reference whenever the logs change — and a STABLE one between changes (never
// re-slice per call, which would loop the renderer). The old behaviour mutated
// `logs` in place and returned that same array, so the log console never
// updated live: a log produced while the panel was open was never re-rendered.
let logsSnapshot: readonly LogEntry[] = [];
function refreshSnapshot() {
logsSnapshot = logs.slice();
}
let initialized = false; let initialized = false;
function loadFromStorage() { function loadFromStorage() {
@ -21,6 +33,7 @@ function loadFromStorage() {
if (stored) { if (stored) {
const parsed: LogEntry[] = JSON.parse(stored); const parsed: LogEntry[] = JSON.parse(stored);
logs.push(...parsed); logs.push(...parsed);
refreshSnapshot();
} }
} catch { } catch {
// ignore corrupted storage // ignore corrupted storage
@ -51,6 +64,7 @@ function addEntry(level: LogLevel, args: unknown[]) {
if (logs.length > MAX_ENTRIES) { if (logs.length > MAX_ENTRIES) {
logs.splice(0, logs.length - MAX_ENTRIES); logs.splice(0, logs.length - MAX_ENTRIES);
} }
refreshSnapshot();
saveToStorage(); saveToStorage();
listeners.forEach((fn) => fn()); listeners.forEach((fn) => fn());
@ -83,7 +97,7 @@ export function initLogCapture() {
} }
export function getLogs(): readonly LogEntry[] { export function getLogs(): readonly LogEntry[] {
return logs; return logsSnapshot;
} }
/// Extract the N most recent non-info entries formatted as a single string, /// Extract the N most recent non-info entries formatted as a single string,
@ -103,10 +117,30 @@ export function getRecentErrorLogs(n: number): string {
export function clearLogs() { export function clearLogs() {
logs.length = 0; logs.length = 0;
refreshSnapshot();
saveToStorage(); saveToStorage();
listeners.forEach((fn) => fn()); listeners.forEach((fn) => fn());
} }
// ---------------------------------------------------------------------------
// App logging API. Prefer these over raw `console.*` in application code so
// meaningful events land in the in-app log console (Settings → Systems →
// Journaux / Logs). They forward to the captured console methods, so entries
// also show up in devtools. `console.log` is captured as the `info` level.
// ---------------------------------------------------------------------------
export function logInfo(...args: unknown[]): void {
console.log(...args);
}
export function logWarn(...args: unknown[]): void {
console.warn(...args);
}
export function logError(...args: unknown[]): void {
console.error(...args);
}
export function subscribe(listener: LogListener): () => void { export function subscribe(listener: LogListener): () => void {
listeners.add(listener); listeners.add(listener);
return () => listeners.delete(listener); return () => listeners.delete(listener);

View file

@ -1,9 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { shiftMonth, getCartesSnapshot } from "./reportService"; import { shiftMonth, getCartesSnapshot } from "./reportService";
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db"; import { getDb } from "./db";
@ -231,6 +235,33 @@ describe("getCartesSnapshot", () => {
expect(snapshot.topMoversDown[0].categoryName).toBe("D3"); 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 () => { it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026. // Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
routeSelect([ routeSelect([
@ -438,4 +469,77 @@ describe("getCartesSnapshot", () => {
expect(worst.overrunAbs).toBe(150); expect(worst.overrunAbs).toBe(150);
expect(worst.overrunPct).toBeCloseTo(75, 5); expect(worst.overrunPct).toBeCloseTo(75, 5);
}); });
// --- accountIds filter (Issues #273 / #279) ---
//
// getCartesSnapshot must forward accountIds to EVERY sub-query so the whole
// dashboard honours an active account filter: the two sub-reports that
// support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData
// for budget adherence) AND the KPI/sparkline/seasonality series
// (fetchMonthlyFlows, fetchSeasonality). Omitting it anywhere would let the
// dashboard silently mix filtered and unfiltered figures on the same screen.
it("forwards accountIds to the compare (top movers) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
const momCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("ORDER BY ABS(month_current_total - month_previous_total) DESC"),
)!;
expect(momCall[0]).toContain("AND t.source_id IN ($9, $10)");
expect(momCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the budget-vs-actual (budget adherence) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions\n WHERE date BETWEEN"),
);
// Both the month and YTD actuals queries (getBudgetVsActualData) receive it.
expect(actualsCalls.length).toBeGreaterThanOrEqual(2);
for (const [sql, params] of actualsCalls) {
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(params as unknown[]).toEqual(expect.arrayContaining([3, 8]));
}
});
it("forwards accountIds to the monthly-flows series (KPIs, sparklines, overlay)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchMonthlyFlows is the only snapshot query selecting a "%Y-%m" month.
const flowCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%Y-%m', date) AS month"),
)!;
expect(flowCall[0]).toContain("AND source_id IN ($3, $4)");
expect(flowCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the seasonality series", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchSeasonality is the only snapshot query filtering on a "%m" month.
const seasonalityCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%m', date) = $1"),
)!;
expect(seasonalityCall[0]).toContain("AND source_id IN ($4, $5)");
expect(seasonalityCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3);
for (const [sql] of mockSelect.mock.calls) {
expect(sql as string).not.toContain("source_id");
}
});
}); });

View file

@ -1,16 +1,24 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { import {
getMonthlyTrends,
getCategoryOverTime, getCategoryOverTime,
getHighlights, getHighlights,
getCompareMonthOverMonth, getCompareMonthOverMonth,
getCompareYearOverYear, getCompareYearOverYear,
getCategoryZoom, getCategoryZoom,
buildCompareTree,
buildOverTimeTree,
} from "./reportService"; } from "./reportService";
import type { CategoryDelta, OverTimeRow } from "../shared/types";
// Mock the db module // Mock the db module
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db"; import { getDb } from "./db";
@ -22,6 +30,66 @@ beforeEach(() => {
mockSelect.mockReset(); mockSelect.mockReset();
}); });
describe("getMonthlyTrends", () => {
it("builds query without WHERE clause when no filters are provided (regression: byte-identical to pre-#273)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends();
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("WHERE");
expect(params).toEqual([]);
});
it("applies date range filters without an accountIds argument (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31");
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("date >= $1");
expect(sql).toContain("date <= $2");
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31", []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31", [3, 7]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("source_id IN ($3, $4)");
expect(sql).not.toContain("3,7");
expect(params).toEqual(["2025-01-01", "2025-12-31", 3, 7]);
});
it("applies the accountIds filter alone (no dates)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends(undefined, undefined, [5]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("WHERE source_id IN ($1)");
expect(params).toEqual([5]);
});
});
describe("getCategoryOverTime", () => { describe("getCategoryOverTime", () => {
it("builds query without WHERE clause when no filters are provided", async () => { it("builds query without WHERE clause when no filters are provided", async () => {
// First call: top categories, second call: monthly breakdown // First call: top categories, second call: monthly breakdown
@ -80,74 +148,171 @@ describe("getCategoryOverTime", () => {
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]); expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
}); });
it("applies sourceId filter", async () => { it("applies accountIds filter (single id)", async () => {
mockSelect mockSelect
.mockResolvedValueOnce([]) // topCategories .mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows .mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime(undefined, undefined, 50, 3); await getCategoryOverTime(undefined, undefined, 50, [3]);
const topCatSQL = mockSelect.mock.calls[0][0] as string; const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[]; const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id"); expect(topCatSQL).toContain("t.source_id IN ($1)");
expect(topCatParams[0]).toBe(3); expect(topCatParams[0]).toBe(3);
}); });
it("combines typeFilter, date range, and sourceId", async () => { it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => {
mockSelect mockSelect
.mockResolvedValueOnce([]) // topCategories .mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows .mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense"); await getCategoryOverTime(undefined, undefined, 50, [3, 7, 12]);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id IN ($1, $2, $3)");
expect(topCatSQL).not.toContain("3,7,12");
// topN ($4) follows the 3 account placeholders.
expect(topCatParams).toEqual([3, 7, 12, 50]);
});
it("an empty accountIds array behaves exactly like no filter (no clause, no orphan param)", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-12-31", 50, []);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).not.toContain("source_id");
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
});
it("combines typeFilter, date range, and accountIds", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, [2], "expense");
const topCatSQL = mockSelect.mock.calls[0][0] as string; const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[]; const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1"); expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
expect(topCatSQL).toContain("t.date >= $2"); expect(topCatSQL).toContain("t.date >= $2");
expect(topCatSQL).toContain("t.date <= $3"); expect(topCatSQL).toContain("t.date <= $3");
expect(topCatSQL).toContain("t.source_id = $4"); expect(topCatSQL).toContain("t.source_id IN ($4)");
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]); expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
}); });
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 mockSelect
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 }, { category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
{ category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 }, { category_id: 2, category_name: "Salary", category_color: "#00ff00", category_type: "income", total: 200 },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }, { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 }, { month: "2025-01", category_id: 2, category_name: "Salary", total: 100 },
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 }, { 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"); const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
expect(result.categories).toEqual(["Food", "Transport"]); expect(result.categories).toEqual(["Food", "Salary"]);
expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" }); expect(result.colors).toEqual({ Food: "#ff0000", Salary: "#00ff00" });
expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 }); expect(result.categoryIds).toEqual({ Food: 1, Salary: 2 });
expect(result.types).toEqual({ Food: "expense", Salary: "income" });
expect(result.data).toHaveLength(2); expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 }); expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Salary: 100 });
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 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 mockSelect
.mockResolvedValueOnce([ .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([ .mockResolvedValueOnce([
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }, { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 }, { month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
{ month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 }, { month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
])
// 3. category metadata for the tree.
.mockResolvedValueOnce([
{ id: 1, name: "Food", color: "#ff0000", type: "expense", parent_id: null },
{ id: 2, name: "Transport", color: "#00ff00", type: "expense", parent_id: null },
{ id: 3, name: "Entertainment", color: "#0000ff", type: "expense", parent_id: null },
]); ]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense"); const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");
// Pivot path is UNCHANGED — top-N (Food) plus an "Other" bucket for the rest.
expect(result.categories).toEqual(["Food", "Other"]); expect(result.categories).toEqual(["Food", "Other"]);
expect(result.colors["Other"]).toBe("#9ca3af"); expect(result.colors["Other"]).toBe("#9ca3af");
expect(result.types).toEqual({ Food: "expense" });
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 }); expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
// Sidecar tree — EVERY category (no top-N, no "Other"), keyed by id.
const treeNames = result.tree.map((r) => r.categoryName);
expect(treeNames).toEqual(expect.arrayContaining(["Food", "Transport", "Entertainment"]));
expect(treeNames).not.toContain("Other");
const food = result.tree.find((r) => r.categoryId === 1)!;
expect(food.monthly).toEqual([300]);
expect(food.total).toBe(300);
expect(food.category_type).toBe("expense");
expect(food.is_parent).toBe(false);
});
it("separates homonym categories of different types in the tree (Issue #264)", async () => {
mockSelect
.mockResolvedValueOnce([]) // top categories (irrelevant to the tree)
.mockResolvedValueOnce([
// Two DIFFERENT categories both named "Divers".
{ month: "2025-01", category_id: 1, category_name: "Divers", total: 1000 },
{ month: "2025-01", category_id: 2, category_name: "Divers", total: 300 },
])
.mockResolvedValueOnce([
{ id: 1, name: "Divers", color: null, type: "income", parent_id: null },
{ id: 2, name: "Divers", color: null, type: "expense", parent_id: null },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31");
// The pivot merges them (single "Divers" cell); the tree keeps them apart.
const incomeDivers = result.tree.find((r) => r.categoryId === 1)!;
const expenseDivers = result.tree.find((r) => r.categoryId === 2)!;
expect(incomeDivers.category_type).toBe("income");
expect(incomeDivers.total).toBe(1000);
expect(expenseDivers.category_type).toBe("expense");
expect(expenseDivers.total).toBe(300);
}); });
}); });
@ -321,7 +486,10 @@ describe("getCompareMonthOverMonth", () => {
await getCompareMonthOverMonth(2026, 4); await getCompareMonthOverMonth(2026, 4);
expect(mockSelect).toHaveBeenCalledTimes(1); // Two queries now: the delta aggregation (call 0, unchanged) + the category
// metadata for the hierarchy (call 1, Issue #247).
expect(mockSelect).toHaveBeenCalledTimes(2);
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
const sql = mockSelect.mock.calls[0][0] as string; const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[]; const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("$1"); expect(sql).toContain("$1");
@ -413,6 +581,46 @@ describe("getCompareMonthOverMonth", () => {
expect(result[0].deltaAbs).toBe(50); expect(result[0].deltaAbs).toBe(50);
expect(result[0].cumulativeDeltaAbs).toBe(80); expect(result[0].cumulativeDeltaAbs).toBe(80);
}); });
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4, []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4, [3, 7]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("AND t.source_id IN ($9, $10)");
expect(sql).not.toContain("3,7");
expect(params).toEqual([
"2026-04-01", "2026-04-30",
"2026-03-01", "2026-03-31",
"2026-01-01", "2026-04-30",
"2026-01-01", "2026-03-31",
3, 7,
]);
});
}); });
describe("getCompareYearOverYear", () => { describe("getCompareYearOverYear", () => {
@ -470,6 +678,152 @@ describe("getCompareYearOverYear", () => {
expect(result[0].deltaPct).toBeCloseTo(50, 4); expect(result[0].deltaPct).toBeCloseTo(50, 4);
expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4); expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4);
}); });
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4, [9]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("AND t.source_id IN ($9)");
expect(params).toEqual([
"2026-04-01", "2026-04-30",
"2025-04-01", "2025-04-30",
"2026-01-01", "2026-04-30",
"2025-01-01", "2025-04-30",
9,
]);
});
});
describe("real-vs-real compare — transfer netting (Issue #243)", () => {
// A `transfer` category (e.g. "Paiement CC") is a money move, not spending:
// its debit and matching credit must cancel to ~0 in the real-vs-real
// 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 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
// the netting is pinned two ways: (1) a reference-semantics check over
// concrete debit/credit rows that reproduces the exact per-row rule the SQL
// encodes, and (2) structural assertions that the real query on BOTH compare
// functions carries that rule and the broadened WHERE.
/** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */
function bucketContribution(amount: number, type: string | null): number {
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; // 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", () => {
// "Paiement CC" (transfer): -500 out of chequing, +500 onto the card.
const transfer = [-500, 500].reduce((s, a) => s + bucketContribution(a, "transfer"), 0);
expect(transfer).toBeCloseTo(0, 6);
// "Épicerie" (expense): two debits + one refund credit that must NOT net it down.
const expense = [-120, -80, 60].reduce((s, a) => s + bucketContribution(a, "expense"), 0);
expect(expense).toBe(200);
// 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 () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4);
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') 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') IN ('transfer', 'income'))",
);
// still four date buckets
expect(sql).toContain("month_current_total");
expect(sql).toContain("cumulative_previous_total");
});
it("getCompareYearOverYear applies the identical netting contract (both tabs fixed)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain(
"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') IN ('transfer', 'income'))",
);
});
it("keeps a netted transfer at 0 through the pipeline while an expense is unchanged", async () => {
// Rows as the netted SQL returns them: the balanced transfer arrives at 0,
// the ordinary expense keeps its outflow sum. Assert nothing downstream
// re-inflates the transfer.
mockSelect.mockResolvedValueOnce([
{
category_id: 5,
category_name: "Épicerie",
category_color: "#10b981",
month_current_total: 200,
month_previous_total: 150,
cumulative_current_total: 800,
cumulative_previous_total: 600,
},
{
category_id: 71,
category_name: "Paiement CC",
category_color: "#9ca3af",
month_current_total: 0,
month_previous_total: 0,
cumulative_current_total: 0,
cumulative_previous_total: 0,
},
]);
const result = await getCompareMonthOverMonth(2026, 4);
const transferRow = result.find((r) => r.categoryName === "Paiement CC")!;
const expenseRow = result.find((r) => r.categoryName === "Épicerie")!;
expect(transferRow.currentAmount).toBe(0);
expect(transferRow.previousAmount).toBe(0);
expect(transferRow.deltaAbs).toBe(0);
expect(transferRow.cumulativeCurrentAmount).toBe(0);
// Ordinary expense keeps EXACTLY its sum of outflows.
expect(expenseRow.currentAmount).toBe(200);
expect(expenseRow.previousAmount).toBe(150);
});
}); });
describe("getCategoryZoom", () => { describe("getCategoryZoom", () => {
@ -526,3 +880,277 @@ describe("getCategoryZoom", () => {
expect(txSql).toContain("t.category_id = $1"); expect(txSql).toContain("t.category_id = $1");
}); });
}); });
describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
// Flat leaf, mirroring what rowsToDeltas returns from COMPARE_DELTA_SQL.
function leaf(
categoryId: number | null,
categoryName: string,
mc: number,
mp: number,
cc = mc,
cp = mp,
): CategoryDelta {
return {
categoryId,
categoryName,
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,
};
}
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: 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", () => {
const rows = buildCompareTree(
[leaf(22, "Épicerie", 500, 400, 2000, 1500), leaf(24, "Restaurant", 120, 200, 300, 500)],
cats,
);
// Parent subtotal on top, then children ordered by |monthly delta| 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 = sum of children (the group's net).
expect(parent.currentAmount).toBe(620);
expect(parent.previousAmount).toBe(600);
expect(parent.deltaAbs).toBe(20);
expect(parent.cumulativeCurrentAmount).toBe(2300);
expect(parent.cumulativePreviousAmount).toBe(2000);
expect(parent.cumulativeDeltaAbs).toBe(300);
// Children are leaves at depth 1 with UNCHANGED values (no regression).
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
expect(epicerie.is_parent).toBeFalsy();
expect(epicerie.depth).toBe(1);
expect(epicerie.parent_id).toBe(2);
expect(epicerie.currentAmount).toBe(500);
expect(epicerie.previousAmount).toBe(400);
expect(epicerie.deltaAbs).toBe(100);
});
it("keeps a balanced transfer group netted to 0 at the subtotal (preserves #243)", () => {
const rows = buildCompareTree([leaf(50, "REER", 0, 0, 0, 0)], cats);
const parent = rows.find((r) => r.categoryId === 5)!;
expect(parent.is_parent).toBe(true);
expect(parent.category_type).toBe("transfer");
// The netted-to-zero transfer stays 0 through the subtotal — not re-inflated.
expect(parent.currentAmount).toBe(0);
expect(parent.previousAmount).toBe(0);
expect(parent.deltaAbs).toBe(0);
});
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
const rows = buildCompareTree(
[leaf(80, "Paie", 4200, 4000), leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
cats,
);
const types = rows.map((r) => r.category_type);
// 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", () => {
const flat = [
leaf(22, "Épicerie", 500, 400, 2000, 1500),
leaf(24, "Restaurant", 120, 200, 300, 500),
leaf(50, "REER", 0, 0, 0, 0),
];
const rows = buildCompareTree(flat, cats);
const sumLeaves = (rs: CategoryDelta[]) =>
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.currentAmount, 0);
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
});
it("surfaces an uncategorized (null id) row as a top-level leaf", () => {
const rows = buildCompareTree([leaf(null, "Uncategorized", 90, 40)], cats);
expect(rows).toHaveLength(1);
expect(rows[0].categoryName).toBe("Uncategorized");
expect(rows[0].is_parent).toBeFalsy();
expect(rows[0].depth).toBe(0);
expect(rows[0].currentAmount).toBe(90);
});
it("keeps a leaf whose category was soft-deleted (still in the metadata)", () => {
// A single leaf with no siblings collapses to just that leaf (no lone subtotal).
const rows = buildCompareTree([leaf(22, "Épicerie", 75, 60)], cats);
const names = rows.map((r) => r.categoryName);
expect(names).toContain("Épicerie");
// A parent with exactly one contributing child still gets a subtotal row.
const parent = rows.find((r) => r.is_parent);
expect(parent?.currentAmount).toBe(75);
});
});
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
// 1. COMPARE_DELTA_SQL — two sibling leaves under parent id 2.
.mockResolvedValueOnce([
{
category_id: 22,
category_name: "Épicerie",
category_color: "#10b981",
month_current_total: 500,
month_previous_total: 400,
cumulative_current_total: 2000,
cumulative_previous_total: 1500,
},
{
category_id: 24,
category_name: "Restaurant",
category_color: "#f97316",
month_current_total: 120,
month_previous_total: 200,
cumulative_current_total: 300,
cumulative_previous_total: 500,
},
])
// 2. COMPARE_CATEGORIES_SQL — the hierarchy.
.mockResolvedValueOnce([
{ 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 },
]);
const result = await getCompareMonthOverMonth(2026, 4);
// Delta query stays call 0 (netting SQL + params assertions elsewhere rely on it).
expect(mockSelect.mock.calls[0][0]).toContain("month_current_total");
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
const parent = result.find((r) => r.is_parent);
expect(parent).toBeDefined();
expect(parent!.categoryId).toBe(2);
expect(parent!.currentAmount).toBe(620);
// Leaves survive unchanged.
expect(result.find((r) => r.categoryName === "Épicerie")!.currentAmount).toBe(500);
});
});

View file

@ -1,10 +1,12 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService"; import { getBudgetVsActualData } from "./budgetService";
import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
MonthlyTrendItem, MonthlyTrendItem,
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, CategoryOverTimeData,
CategoryOverTimeItem, CategoryOverTimeItem,
OverTimeRow,
HighlightsData, HighlightsData,
HighlightMover, HighlightMover,
CategoryDelta, CategoryDelta,
@ -28,7 +30,7 @@ import type {
export async function getMonthlyTrends( export async function getMonthlyTrends(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
sourceId?: number, accountIds?: number[],
): Promise<MonthlyTrendItem[]> { ): Promise<MonthlyTrendItem[]> {
const db = await getDb(); const db = await getDb();
@ -46,10 +48,11 @@ export async function getMonthlyTrends(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) { const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
whereClauses.push(`source_id = $${paramIndex}`); if (accountPlaceholders) {
params.push(sourceId); whereClauses.push(`source_id IN (${accountPlaceholders})`);
paramIndex++; params.push(...accountIds!);
paramIndex += accountIds!.length;
} }
const whereSQL = const whereSQL =
@ -72,7 +75,7 @@ export async function getCategoryOverTime(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
topN: number = 50, topN: number = 50,
sourceId?: number, accountIds?: number[],
typeFilter?: "expense" | "income" | "transfer", typeFilter?: "expense" | "income" | "transfer",
): Promise<CategoryOverTimeData> { ): Promise<CategoryOverTimeData> {
const db = await getDb(); const db = await getDb();
@ -97,20 +100,24 @@ export async function getCategoryOverTime(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) { const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
whereClauses.push(`t.source_id = $${paramIndex}`); if (accountPlaceholders) {
params.push(sourceId); whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
paramIndex++; params.push(...accountIds!);
paramIndex += accountIds!.length;
} }
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
// Get top N categories by total spend // 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 `SELECT
t.category_id, t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(c.type, 'expense') AS category_type,
ABS(SUM(t.amount)) AS total ABS(SUM(t.amount)) AS total
FROM transactions t FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id 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 topCategoryIds = new Set(topCategories.map((c) => c.category_id));
const colors: Record<string, string> = {}; const colors: Record<string, string> = {};
const categoryIds: Record<string, number | null> = {}; const categoryIds: Record<string, number | null> = {};
const types: Record<string, "expense" | "income" | "transfer"> = {};
for (const cat of topCategories) { for (const cat of topCategories) {
colors[cat.category_name] = cat.category_color; colors[cat.category_name] = cat.category_color;
categoryIds[cat.category_name] = cat.category_id; categoryIds[cat.category_name] = cat.category_id;
types[cat.category_name] = cat.category_type ?? "expense";
} }
// Get monthly breakdown for all categories // Get monthly breakdown for all categories
@ -178,11 +187,58 @@ export async function getCategoryOverTime(
categories.push("Other"); categories.push("Other");
} }
const data = Array.from(monthMap.values());
// --- Id-keyed hierarchical sidecar (Issue #264) ---
// The pivot above stays byte-identical (top-N + "Other", name-keyed) so the
// chart and dashboard are untouched. The tree is a SEPARATE view of the exact
// same `monthlyRows`, keyed by category_id, carrying EVERY category (no top-N,
// no "Other" bucket) so the trends table + its result lines are exact and two
// homonym categories of different types never collide.
const months = data.map((d) => d.month);
const monthIndex = new Map<string, number>();
months.forEach((m, i) => monthIndex.set(m, i));
// Aggregate every category into an id-keyed per-month magnitude series. A null
// category_id (truly uncategorized) collapses to one bucket; a non-null id that
// has no metadata row (hard-deleted) stays distinct and surfaces as an orphan.
const leafByKey = new Map<number | string, OverTimeRow>();
for (const row of monthlyRows) {
const key = row.category_id ?? "__uncategorized__";
let leaf = leafByKey.get(key);
if (!leaf) {
leaf = {
categoryId: row.category_id,
categoryName: row.category_name,
categoryColor: "#9ca3af",
monthly: new Array(months.length).fill(0),
total: 0,
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
leafByKey.set(key, leaf);
}
const idx = monthIndex.get(row.month);
if (idx != null) {
leaf.monthly[idx] += row.total;
leaf.total += row.total;
}
}
const cats = await db.select<TreeCatMeta[]>(
`SELECT id, name, color, type, parent_id FROM categories`,
);
const tree = buildOverTimeTree(Array.from(leafByKey.values()), cats ?? [], months.length);
return { return {
categories, categories,
data: Array.from(monthMap.values()), data,
colors, colors,
categoryIds, categoryIds,
types,
tree,
}; };
} }
@ -429,21 +485,449 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` }; return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
} }
// --- 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 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).
*/
export interface TreeCatMeta {
id: number;
name: string;
color: string | null;
type: TreeSectionType | null;
parent_id: number | null;
}
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
const MAX_TREE_DEPTH = 5;
/**
* 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 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 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(l);
}
}
// "Relevant" = every category that has a leaf plus all of its ancestors.
const relevant = new Set<number>();
for (const id of leafByCat.keys()) {
let cur: number | null | undefined = id;
let guard = 0;
while (cur != null && guard <= maxDepth) {
if (relevant.has(cur)) break;
relevant.add(cur);
cur = catById.get(cur)?.parent_id ?? null;
guard++;
}
}
// Adjacency among relevant categories only, preserving DB order.
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);
if (!arr) childrenByParent.set(c.parent_id, (arr = []));
arr.push(c);
}
}
interface Block {
rows: T[];
sortKey: number; // magnitude 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 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),
}),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
let previousAmount = 0;
let currentAmount = 0;
let cumulativePreviousAmount = 0;
let cumulativeCurrentAmount = 0;
for (const l of descendantLeaves) {
previousAmount += l.previousAmount;
currentAmount += l.currentAmount;
cumulativePreviousAmount += l.cumulativePreviousAmount;
cumulativeCurrentAmount += l.cumulativeCurrentAmount;
}
const deltaAbs = currentAmount - previousAmount;
const cumulativeDeltaAbs = cumulativeCurrentAmount - cumulativePreviousAmount;
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
previousAmount,
currentAmount,
deltaAbs,
// Match rowsToDeltas' leaf formula (signed denominator) so a single-child
// parent shows the same % as its child.
deltaPct: previousAmount !== 0 ? (deltaAbs / previousAmount) * 100 : null,
cumulativePreviousAmount,
cumulativeCurrentAmount,
cumulativeDeltaAbs,
cumulativeDeltaPct:
cumulativePreviousAmount !== 0
? (cumulativeDeltaAbs / cumulativePreviousAmount) * 100
: null,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
};
},
// 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,
});
}
// --- 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),
};
},
// 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,
});
}
function previousMonth(year: number, month: number): { year: number; month: number } { function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 }; if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 }; return { year, month: month - 1 };
} }
/**
* Shared aggregation for the real-vs-real compare (both MoM and YoY drive the
* exact same query only the eight date bounds differ). Four date buckets
* ($1..$8): monthly current/previous and cumulative current/previous.
*
* 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 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.
*/
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') 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') 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 * Month-over-month expense delta by category. Returns both a monthly view
* (reference month vs immediately-previous month) and a cumulative YTD view * (reference month vs immediately-previous month) and a cumulative YTD view
* (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative * (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative
* progress through end of last month" vs "cumulative progress through end of * progress through end of last month" vs "cumulative progress through end of
* this month"). All SQL parameterised. * this month"). All SQL parameterised. `accountIds` (Issue #273) optionally
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/ */
export async function getCompareMonthOverMonth( export async function getCompareMonthOverMonth(
year: number, year: number,
month: number, month: number,
accountIds?: number[],
): Promise<CategoryDelta[]> { ): Promise<CategoryDelta[]> {
const db = await getDb(); const db = await getDb();
const { start: curStart, end: curEnd } = monthBoundaries(year, month); const { start: curStart, end: curEnd } = monthBoundaries(year, month);
@ -461,34 +945,22 @@ export async function getCompareMonthOverMonth(
const cumPreviousStart = `${prev.year}-01-01`; const cumPreviousStart = `${prev.year}-01-01`;
const cumPreviousEnd = prevEnd; const cumPreviousEnd = prevEnd;
const rows = await db.select<RawDeltaRow[]>( const deltaParams: unknown[] = [
`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 ABS(t.amount) ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) 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
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)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`,
[
curStart, curEnd, curStart, curEnd,
prevStart, prevEnd, prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd, cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd, cumPreviousStart, cumPreviousEnd,
], ];
); if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
return rowsToDeltas(rows);
// 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 ?? []);
} }
/** /**
@ -497,11 +969,13 @@ export async function getCompareMonthOverMonth(
* view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the * view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the
* reference year's December as the "current month" when no explicit * reference year's December as the "current month" when no explicit
* reference month is provided; callers typically pass the user's chosen * reference month is provided; callers typically pass the user's chosen
* reference month. All SQL parameterised. * reference month. All SQL parameterised. `accountIds` (Issue #273) optionally
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/ */
export async function getCompareYearOverYear( export async function getCompareYearOverYear(
year: number, year: number,
month: number = 12, month: number = 12,
accountIds?: number[],
): Promise<CategoryDelta[]> { ): Promise<CategoryDelta[]> {
const db = await getDb(); const db = await getDb();
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month); const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
@ -512,34 +986,20 @@ export async function getCompareYearOverYear(
const cumPreviousStart = `${year - 1}-01-01`; const cumPreviousStart = `${year - 1}-01-01`;
const cumPreviousEnd = prevMonthEnd; const cumPreviousEnd = prevMonthEnd;
const rows = await db.select<RawDeltaRow[]>( const deltaParams: unknown[] = [
`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 ABS(t.amount) ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) 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
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)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`,
[
curMonthStart, curMonthEnd, curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd, prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd, cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd, cumPreviousStart, cumPreviousEnd,
], ];
); if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
return rowsToDeltas(rows);
// 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 ?? []);
} }
// --- Category zoom (Issue #74) --- // --- Category zoom (Issue #74) ---
@ -726,18 +1186,25 @@ interface RawMonthFlow {
async function fetchMonthlyFlows( async function fetchMonthlyFlows(
dateFrom: string, dateFrom: string,
dateTo: string, dateTo: string,
accountIds?: number[],
): Promise<RawMonthFlow[]> { ): Promise<RawMonthFlow[]> {
const db = await getDb(); const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, 3);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawMonthFlow[]>( return db.select<RawMonthFlow[]>(
`SELECT `SELECT
strftime('%Y-%m', date) AS month, strftime('%Y-%m', date) AS month,
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income, COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
FROM transactions FROM transactions
WHERE date >= $1 AND date <= $2 WHERE date >= $1 AND date <= $2${accountClause}
GROUP BY month GROUP BY month
ORDER BY month ASC`, ORDER BY month ASC`,
[dateFrom, dateTo], params,
); );
} }
@ -750,9 +1217,16 @@ async function fetchSeasonality(
month: number, month: number,
yearFrom: number, yearFrom: number,
yearTo: number, yearTo: number,
accountIds?: number[],
): Promise<RawSeasonalityRow[]> { ): Promise<RawSeasonalityRow[]> {
const db = await getDb(); const db = await getDb();
const mm = String(month).padStart(2, "0"); const mm = String(month).padStart(2, "0");
const params: unknown[] = [mm, yearFrom, yearTo];
const accountPlaceholders = inPlaceholders(accountIds, 4);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawSeasonalityRow[]>( return db.select<RawSeasonalityRow[]>(
`SELECT `SELECT
CAST(strftime('%Y', date) AS INTEGER) AS year, CAST(strftime('%Y', date) AS INTEGER) AS year,
@ -760,10 +1234,10 @@ async function fetchSeasonality(
FROM transactions FROM transactions
WHERE strftime('%m', date) = $1 WHERE strftime('%m', date) = $1
AND CAST(strftime('%Y', date) AS INTEGER) >= $2 AND CAST(strftime('%Y', date) AS INTEGER) >= $2
AND CAST(strftime('%Y', date) AS INTEGER) <= $3 AND CAST(strftime('%Y', date) AS INTEGER) <= $3${accountClause}
GROUP BY year GROUP BY year
ORDER BY year DESC`, ORDER BY year DESC`,
[mm, yearFrom, yearTo], params,
); );
} }
@ -779,11 +1253,19 @@ async function fetchSeasonality(
* via the monthly series instead of re-querying. * via the monthly series instead of re-querying.
* 4. Budget vs actual for the reference month. * 4. Budget vs actual for the reference month.
* 5. Seasonality: same calendar month across the two prior years. * 5. Seasonality: same calendar month across the two prior years.
*
* `accountIds` (Issues #273/#279) is forwarded to every sub-query so the whole
* dashboard honours an active account filter: the top-movers
* (`getCompareMonthOverMonth`) and budget (`getBudgetVsActualData`) sub-reports,
* and the KPI/sparkline/seasonality series (`fetchMonthlyFlows`,
* `fetchSeasonality`). Omitting it anywhere would let the dashboard silently mix
* filtered and unfiltered figures on the same screen.
*/ */
export async function getCartesSnapshot( export async function getCartesSnapshot(
referenceYear: number, referenceYear: number,
referenceMonth: number, referenceMonth: number,
mode: CartesKpiPeriodMode = "month", mode: CartesKpiPeriodMode = "month",
accountIds?: number[],
): Promise<CartesSnapshot> { ): Promise<CartesSnapshot> {
// Date window: 25 months back from the reference to cover YoY + a 13-month // Date window: 25 months back from the reference to cover YoY + a 13-month
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24. // sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
@ -793,10 +1275,10 @@ export async function getCartesSnapshot(
// Seasonality range: previous 2 years for the same calendar month. // Seasonality range: previous 2 years for the same calendar month.
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([ const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1), fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1, accountIds),
fetchMonthlyFlows(windowStartIso, refEnd), fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
getCompareMonthOverMonth(referenceYear, referenceMonth), getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
getBudgetVsActualData(referenceYear, referenceMonth), getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
]); ]);
// Index the flow rows by month for O(1) lookup, then fill missing months // Index the flow rows by month for O(1) lookup, then fill missing months
@ -947,10 +1429,22 @@ export async function getCartesSnapshot(
// 12-month income vs expenses series for the overlay chart. // 12-month income vs expenses series for the overlay chart.
const flow12Months = buildSeries(12); const flow12Months = buildSeries(12);
// Top movers: biggest MoM increases / decreases. `momRows` are sorted by // 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. 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. // absolute delta already; filter out near-zero noise and split by sign.
const significantMovers = momRows.filter( const significantMovers = momRows.filter(
(r) => 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 // Project the richer CategoryDelta shape down to the narrower CartesTopMover
// shape so the Cartes dashboard keeps its stable contract regardless of how // 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"; export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
/**
* Shared filter state for the report pages: the active period plus the
* account (import source) subset. `accountIds` is a list of `ImportSource.id`
* not to be confused with the Bilan module's `balance_accounts` ids, a
* disjoint id space. An empty list means "no filter" (all accounts).
*/
export interface ReportFilters {
period: DashboardPeriod;
accountIds: number[];
}
export interface CategoryDelta { export interface CategoryDelta {
categoryId: number | null; categoryId: number | null;
categoryName: string; categoryName: string;
@ -308,6 +319,16 @@ export interface CategoryDelta {
cumulativeCurrentAmount: number; cumulativeCurrentAmount: number;
cumulativeDeltaAbs: number; cumulativeDeltaAbs: number;
cumulativeDeltaPct: number | null; cumulativeDeltaPct: number | null;
// Hierarchy block (Issue #247) — populated ONLY by the real-vs-real Compare
// tree builder (getCompareMonthOverMonth / getCompareYearOverYear). Flat
// consumers (Highlights movers, Cartes top movers) leave these undefined and
// must not read them. Snake_case (unlike the camelCase value fields above) to
// mirror BudgetVsActualRow and stay compatible with the shared `reorderRows`
// util. `is_parent` rows are subtotals whose value = the net of their group.
parent_id?: number | null;
is_parent?: boolean;
depth?: number;
category_type?: "expense" | "income" | "transfer";
} }
// Historical alias — used by the highlights hub. Shape identical to CategoryDelta. // Historical alias — used by the highlights hub. Shape identical to CategoryDelta.
@ -357,11 +378,45 @@ export interface CategoryOverTimeItem {
[categoryName: string]: number | string; [categoryName: string]: number | string;
} }
/**
* One row of the id-keyed trends tree (Issue #264) the sidecar to the
* name-keyed pivot below. Carries a per-month magnitude series instead of a
* single delta, but mirrors `CategoryDelta`'s snake_case hierarchy block
* (`parent_id` / `is_parent` / `depth` / `category_type`) so it composes with
* `collapsibleRows` / `useCollapsibleGroups` without friction. Section/type is
* resolved by `category_id` (never by name), so two homonym categories of
* different types never collide the fix the name-keyed pivot cannot express.
*/
export interface OverTimeRow {
categoryId: number | null;
categoryName: string;
categoryColor: string;
/** Positive magnitude per month, index-aligned to `CategoryOverTimeData.data`. */
monthly: number[];
/** Sum of `monthly` — sibling ordering + row total convenience. */
total: number;
// Hierarchy block — same snake_case names as CategoryDelta (Issue #247/#264).
// `is_parent` rows are subtotals whose series = the sum of their leaf subtree.
parent_id: number | null;
is_parent: boolean;
depth: number;
category_type: "expense" | "income" | "transfer";
}
export interface CategoryOverTimeData { export interface CategoryOverTimeData {
categories: string[]; categories: string[];
data: CategoryOverTimeItem[]; data: CategoryOverTimeItem[];
colors: Record<string, string>; colors: Record<string, string>;
categoryIds: Record<string, number | null>; 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 { 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,123 @@
// csvAutoDetect — holdings-CSV detection tests (Issue #245).
//
// Covers `autoDetectHoldingColumns` (column detection) and `analyzeHoldingsCsv`
// (delimiter/header + full analysis). These helpers are pure and DOM-free, in
// line with the project's "test the extracted pure pieces" convention. The
// numeric parsing + duplicate-merge path lives in `holdingsFromCsvRows`
// (tested in useSnapshotEditor.test.ts).
import { describe, it, expect } from "vitest";
import {
autoDetectHoldingColumns,
analyzeHoldingsCsv,
} from "./csvAutoDetect";
describe("autoDetectHoldingColumns (#245)", () => {
it("detects symbol/quantity/price/book_cost from EN headers", () => {
const data = [
["Symbol", "Quantity", "Price", "Book Cost"],
["AAPL", "10", "150.25", "1200.00"],
["MSFT", "5", "300.50", "1400.00"],
];
expect(autoDetectHoldingColumns(data, true)).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
});
});
it("leaves unit_price + book_cost null when the CSV has no such columns", () => {
const data = [
["Symbol", "Shares"],
["AAPL", "10"],
["MSFT", "5"],
];
const m = autoDetectHoldingColumns(data, true)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBeNull();
expect(m.book_cost).toBeNull();
});
it("never maps a Value (qty×price) column to price or book_cost", () => {
const data = [
["Symbol", "Quantity", "Price", "Value"],
["AAPL", "10", "150.25", "1502.50"],
["MSFT", "4", "300.50", "1202.00"],
];
const m = autoDetectHoldingColumns(data, true)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBe(2);
// The Value column (col 3) must NOT be picked up as the cost basis.
expect(m.book_cost).toBeNull();
});
it("detects headerless CSVs positionally (symbol / int qty / decimal price)", () => {
const data = [
["AAPL", "10", "150.25"],
["MSFT", "5", "300.50"],
["GOOG", "2", "140.10"],
];
const m = autoDetectHoldingColumns(data, false)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBe(2);
});
it("returns null when there are no data rows", () => {
expect(autoDetectHoldingColumns([], true)).toBeNull();
expect(autoDetectHoldingColumns([["Symbol", "Qty"]], true)).toBeNull();
});
});
describe("analyzeHoldingsCsv (#245)", () => {
it("parses an EN comma CSV with a header row", () => {
const csv = "Symbol,Quantity,Price\nAAPL,10,150.25\nMSFT,5,300.50\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.delimiter).toBe(",");
expect(a.hasHeader).toBe(true);
expect(a.headers).toEqual(["Symbol", "Quantity", "Price"]);
expect(a.rows).toEqual([
["AAPL", "10", "150.25"],
["MSFT", "5", "300.50"],
]);
expect(a.mapping).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: null,
});
});
it("handles FR headers (accents), a semicolon delimiter and FR numbers", () => {
const csv =
"Symbole;Quantité;Cours;Coût\nAAPL;10;150,25;1 200,00\nMSFT;5;300,50;1 400,00\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.delimiter).toBe(";");
expect(a.hasHeader).toBe(true);
expect(a.mapping).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
});
expect(a.rows[0]).toEqual(["AAPL", "10", "150,25", "1 200,00"]);
});
it("returns generated headers + all rows for a headerless CSV", () => {
const csv = "AAPL,10,150.25\nMSFT,5,300.50\nGOOG,2,140.10\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.hasHeader).toBe(false);
expect(a.headers).toEqual(["Col 0", "Col 1", "Col 2"]);
expect(a.rows).toHaveLength(3);
expect(a.mapping.symbol).toBe(0);
expect(a.mapping.quantity).toBe(1);
});
it("returns null on empty / whitespace-only content", () => {
expect(analyzeHoldingsCsv("")).toBeNull();
expect(analyzeHoldingsCsv(" \n \n")).toBeNull();
});
});

View file

@ -556,3 +556,301 @@ function isSparseComplementary(
return total > 0 && complementary / total >= 0.7; return total > 0 && complementary / total >= 0.7;
} }
// -----------------------------------------------------------------------------
// Holdings CSV detection (Issue #245) — a detailed balance account can import a
// CSV of positions instead of typing them one by one. This is a SEPARATE flow
// from the transaction import above: `autoDetectConfig` is coupled to the
// date/amount transaction model, whereas a holdings CSV maps to
// symbol / quantity / unit_price / book_cost. Price + book_cost are OPTIONAL
// (flexible price detection): when the CSV has no price column the mapping
// leaves it `null` and the holdings import without a price (the user fetches or
// types it afterwards). Detection is best-effort and the UI lets the user
// adjust every column, so an imperfect guess is always recoverable.
// -----------------------------------------------------------------------------
export interface HoldingColumnMapping {
/** Column index of the security symbol/ticker (required). */
symbol: number;
/** Column index of the quantity held (required). */
quantity: number;
/** Column index of the unit price, or null when the CSV has no price column. */
unit_price: number | null;
/** Column index of the acquisition cost basis, or null when absent. */
book_cost: number | null;
}
export interface HoldingCsvAnalysis {
delimiter: string;
hasHeader: boolean;
/** Header labels (actual cells when `hasHeader`, else `Col 0`, `Col 1`, …). */
headers: string[];
/** DATA rows only (the header row, if any, is stripped). */
rows: string[][];
mapping: HoldingColumnMapping;
}
// Header keyword sets, matched against accent-stripped, alphanumeric-only
// header cells. Bilingual (FR default + EN). Order inside each list is priority
// order for the primary-keyword pass.
const SYMBOL_HEADER_KEYWORDS = ["symbol", "symbole", "ticker", "titre"];
const QUANTITY_HEADER_KEYWORDS = [
"quantity",
"quantite",
"qty",
"qte",
"shares",
"actions",
"parts",
"unites",
"units",
"nombre",
];
const PRICE_HEADER_KEYWORDS = [
"prixunitaire",
"unitprice",
"marketprice",
"price",
"prix",
"cours",
"cotation",
"cloture",
"close",
];
const BOOKCOST_HEADER_KEYWORDS = [
"bookcost",
"costbasis",
"prixderevient",
"acquisition",
"revient",
"cost",
"cout",
];
// Value/market-value columns must never be auto-picked as price or book_cost.
const VALUE_HEADER_KEYWORDS = ["value", "valeur", "montant", "marchande"];
/** Accent-strip + lowercase + keep alphanumerics only (for header matching). */
function normalizeHeaderCell(s: string): string {
return (s ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
/**
* Find the first unused column whose normalized header contains one of the
* keywords (keywords tried in priority order). `exclude` skips columns whose
* header contains any excluded token (e.g. a "value" column for price).
*/
function matchHeaderColumn(
normalizedHeaders: string[],
keywords: string[],
used: Set<number>,
exclude: string[] = []
): number | null {
for (const kw of keywords) {
for (let i = 0; i < normalizedHeaders.length; i++) {
if (used.has(i)) continue;
const h = normalizedHeaders[i];
if (!h) continue;
if (exclude.some((ex) => h.includes(ex))) continue;
if (h.includes(kw)) return i;
}
}
return null;
}
/** Pick the shortest-average-length non-numeric, unused text column (symbols
* are short tokens; a name/description column is longer). */
function pickSymbolColumn(
rows: string[][],
colCount: number,
numericCols: Set<number>,
used: Set<number>
): number | null {
let best: number | null = null;
let bestAvg = Infinity;
for (let col = 0; col < colCount; col++) {
if (used.has(col) || numericCols.has(col)) continue;
let totalLen = 0;
let count = 0;
for (const row of rows) {
const cell = row[col]?.trim();
if (!cell) continue;
totalLen += cell.length;
count++;
}
if (count === 0) continue;
const avg = totalLen / count;
if (avg < bestAvg) {
bestAvg = avg;
best = col;
}
}
return best;
}
/** Pick an unused numeric column, optionally preferring integer-heavy (quantity)
* or decimal-heavy (price) columns. Returns null when none remain. */
function pickNumericColumn(
rows: string[][],
numericCols: number[],
used: Set<number>,
opts: { preferIntegers?: boolean; preferDecimals?: boolean }
): number | null {
let best: number | null = null;
let bestScore = -1;
for (const col of numericCols) {
if (used.has(col)) continue;
let ints = 0;
let decimals = 0;
let nonEmpty = 0;
for (const row of rows) {
const cell = row[col]?.trim();
if (!cell) continue;
const v = parseFrenchAmount(cell);
if (isNaN(v)) continue;
nonEmpty++;
if (Number.isInteger(v)) ints++;
else decimals++;
}
if (nonEmpty === 0) continue;
let score = 1;
if (opts.preferIntegers) score = ints / nonEmpty;
else if (opts.preferDecimals) score = decimals / nonEmpty;
if (score > bestScore) {
bestScore = score;
best = col;
}
}
return best;
}
/**
* Detect the symbol / quantity / unit_price / book_cost columns of a holdings
* CSV. `data` is the parsed 2-D array INCLUDING the header row when
* `hasHeader`. Returns a best-effort mapping (symbol + quantity always set so
* the editor can render), or null only when there is no usable data.
* Exported for unit tests.
*/
export function autoDetectHoldingColumns(
data: string[][],
hasHeader: boolean
): HoldingColumnMapping | null {
if (data.length === 0) return null;
const colCount = Math.max(...data.map((r) => r.length));
if (colCount === 0) return null;
const dataRows = hasHeader ? data.slice(1) : data;
if (dataRows.length === 0) return null;
const used = new Set<number>();
let symbol: number | null = null;
let quantity: number | null = null;
let unitPrice: number | null = null;
let bookCost: number | null = null;
// Step 1 — header keyword matching (most reliable when present).
if (hasHeader) {
const normalized = data[0].map(normalizeHeaderCell);
symbol = matchHeaderColumn(normalized, SYMBOL_HEADER_KEYWORDS, used);
if (symbol !== null) used.add(symbol);
quantity = matchHeaderColumn(normalized, QUANTITY_HEADER_KEYWORDS, used);
if (quantity !== null) used.add(quantity);
unitPrice = matchHeaderColumn(
normalized,
PRICE_HEADER_KEYWORDS,
used,
VALUE_HEADER_KEYWORDS
);
if (unitPrice !== null) used.add(unitPrice);
bookCost = matchHeaderColumn(
normalized,
BOOKCOST_HEADER_KEYWORDS,
used,
VALUE_HEADER_KEYWORDS
);
if (bookCost !== null) used.add(bookCost);
}
// A value / market-value column must never be auto-assigned to a numeric
// target by the heuristics below (it is qty × price, not a source column).
// Mark such columns used up-front so the fallback pickers skip them.
if (hasHeader) {
const normalized = data[0].map(normalizeHeaderCell);
normalized.forEach((h, i) => {
if (!used.has(i) && VALUE_HEADER_KEYWORDS.some((v) => h && h.includes(v))) {
used.add(i);
}
});
}
// Step 2 — numeric/text heuristics fill any column the header didn't resolve.
const sampleRows = dataRows.slice(0, 20);
const numericCols = detectNumericColumns(sampleRows, colCount);
const numericSet = new Set(numericCols);
if (symbol === null) {
symbol = pickSymbolColumn(sampleRows, colCount, numericSet, used);
if (symbol !== null) used.add(symbol);
}
if (quantity === null) {
quantity = pickNumericColumn(sampleRows, numericCols, used, {
preferIntegers: true,
});
if (quantity !== null) used.add(quantity);
}
if (unitPrice === null) {
unitPrice = pickNumericColumn(sampleRows, numericCols, used, {
preferDecimals: true,
});
if (unitPrice !== null) used.add(unitPrice);
}
if (bookCost === null) {
bookCost = pickNumericColumn(sampleRows, numericCols, used, {});
if (bookCost !== null) used.add(bookCost);
}
// symbol + quantity are required; fall back to sane defaults so the editor
// always has something to show (the user can correct it).
if (symbol === null) symbol = 0;
if (quantity === null) quantity = symbol === 0 && colCount > 1 ? 1 : 0;
return { symbol, quantity, unit_price: unitPrice, book_cost: bookCost };
}
/**
* Full holdings-CSV analysis: preprocess quoted lines, detect the delimiter and
* header, then the column mapping. Returns the header labels + DATA rows (header
* stripped) so the caller can render a mapping editor + preview and feed the
* rows to `holdingsFromCsvRows`. Null when the content has no usable rows.
* Exported for unit tests.
*/
export function analyzeHoldingsCsv(
rawContent: string
): HoldingCsvAnalysis | null {
const content = preprocessQuotedCSV(rawContent);
const nonEmptyLines = content.split(/\r?\n/).filter((l) => l.trim());
if (nonEmptyLines.length === 0) return null;
const delimiter = detectDelimiter(nonEmptyLines.slice(0, 10));
if (!delimiter) return null;
const parsed = Papa.parse(content, { delimiter, skipEmptyLines: true });
const data = (parsed.data as string[][]).filter((r) =>
r.some((c) => (c ?? "").trim() !== "")
);
if (data.length === 0) return null;
const hasHeader = detectHeader(data[0]);
const mapping = autoDetectHoldingColumns(data, hasHeader);
if (!mapping) return null;
const colCount = Math.max(...data.map((r) => r.length));
const headers = hasHeader
? data[0].map((h, i) => (h ?? "").trim() || `Col ${i}`)
: Array.from({ length: colCount }, (_, i) => `Col ${i}`);
const rows = hasHeader ? data.slice(1) : data;
return { delimiter, hasHeader, headers, rows, mapping };
}

View file

@ -2,9 +2,13 @@
* Shared utility for reordering budget table rows. * Shared utility for reordering budget table rows.
* Recursively moves subtotal (parent) rows below their children * Recursively moves subtotal (parent) rows below their children
* at every depth level when "subtotals on bottom" is enabled. * at every depth level when "subtotals on bottom" is enabled.
*
* The generic constraint only requires the two fields the algorithm actually
* reads (`is_parent`, `depth`) so it works for both the budget rows (required
* `is_parent`) and the Compare `CategoryDelta` rows (optional `is_parent`).
*/ */
export function reorderRows< export function reorderRows<
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number }, T extends { is_parent?: boolean; depth?: number },
>(rows: T[], subtotalsOnTop: boolean): T[] { >(rows: T[], subtotalsOnTop: boolean): T[] {
if (subtotalsOnTop) return rows; if (subtotalsOnTop) return rows;

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