Replace the two hand-rolled Set-of-ids collapse state machines in the category
trees with the shared useCollapsibleGroups hook (a strict superset after #288),
keeping each tree's distinct recursive render and CategoryTree's drag-and-drop
untouched.
- CategoryTree (Categories page): storageKey null + defaultExpanded true, so
every parent opens with no seeding; drops the local Set + collectExpandable.
- CategoryTaxonomyTree + guide page: storageKey null + defaultExpanded false
(collapsed by default); exports a shared TAXONOMY_COLLAPSE_ACCESSORS.
- Fix the guide's button bug: allExpanded = expanded.size > 0 flipped to
"Collapse all" after opening a single node; now uses the hook's correct
allExpanded (every group must be open).
- Also migrate StepDiscover (4th consumer of CategoryTaxonomyTree, same button
bug) onto the hook for a green build and consistency.
Both trees pass a flattened node list to the bulk ops; behaviour preserved:
Categories opens expanded, the guide/wizard open collapsed.
Resolves#290
Generated autonomously by /autopilot run of 2026-07-15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverse #278's deliberate no-collapse decision for the budget grid: it now
folds/unfolds at every category level like the hierarchical reports, opening
fully collapsed with a one-click "Expand all".
- BudgetTable: wire useCollapsibleGroups (defaultExpanded: false), inline
BUDGET_COLLAPSE_ACCESSORS + BUDGET_EXPANDED_KEY (mirrors ComparePeriodTable /
BudgetVsActualTable — no budgetTableModel.ts). Chevron + aria-expanded +
aria-level on every parent row; groups.visible(group) before reorderRows.
- BudgetTable: section subtotal now uses the tested sumLeavesForType on the RAW
group (drop-in for the hand-rolled loop) so folding stays purely visual.
- BudgetTable: rename STORAGE_KEY to "budget-subtotals-position", decoupling the
subtotals-position preference from BudgetVsActualTable (they collided).
- useBudget: extract the pure buildBudgetYearRows(); the grid's rows are
level-order (BFS), not DFS — document the invariant and pin it in a test, since
the #288 ancestor-walk collapse is order-independent (the v1 plan assumed DFS
and would have broken here).
- Tests: useBudget.test.ts locks the level-order emission, the DFS-killer, the
end-to-end multi-level collapse on real builder output, and that subtotals sum
raw rows regardless of collapse. 828 vitest pass.
Resolves#289
Generated autonomously by /autopilot run of 2026-07-15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize the report category collapse from level-1-only to every hierarchy
level, on the three hierarchical report tables (real-vs-real Compare,
real-vs-budget Compare, Trends by category). Visibility is now decided by an
ancestor walk, not by row adjacency, so it is independent of row order (the
level-ordered budget grid emits a non-DFS order).
- collapsibleRows: rewrite visibleRows as an ancestor walk (a row is hidden iff
any ancestor is collapsed); add parentKeyOf + injective `p:` keys;
collapsibleKeys returns all parents (any depth); extract the pure, tested
isCollapsedFor polarity helper; MAX_TREE_DEPTH cycle guard.
- useCollapsibleGroups: persist in user_preferences (per-profile, destroyed with
the profile) instead of localStorage; storageKey nullable (no persistence);
options.defaultExpanded; async hydration (no flash); collapseAll(rows).
- 3 tables: fix BOTH gates (collapsed flag + button) isTopParent -> isParent, add
parentKeyOf accessors, aria-level on parent rows.
- Delete dead CategoryTable.tsx (0 imports).
- Tests: rewrite collapsibleRows.test.ts (BFS==DFS masking, cycle guard,
cross-section ancestor, "(direct)" leaf, polarity); extend overTimeTableModel
fixture to 3 levels with cascade assertions.
Collapse stays purely visual: subtotals and result lines are computed from raw
rows, never from visible rows.
Resolves#288
Generated autonomously by /autopilot run of 2026-07-15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lessons from the v0.13.0 release (session fdda84cb):
- Step 0: revalidate the tip locally before tagging — check.yml never
runs on main, and ensure .claude/worktrees/ is empty (vitest recurses)
- Step 9: verify the published release — 7 expected artifacts and
latest.json content (drives auto-update); status=success is not enough
- Rule: tagging publishes externally via the updater JSON — confirm
with Max before tagging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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
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
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
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>
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
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
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
/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>
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>
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>