Commit graph

168 commits

Author SHA1 Message Date
le king fu
f377d760af fix(export): preserve import sources and templates across data export/import
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
Exporting then re-importing data destroyed every import configuration:
dataExportService serialised only categories, suppliers, keywords and
transactions, then ran DELETE FROM import_sources on restore and replaced
them with a synthetic 'Data Import' source. After restoring a backup, every
source had to be reconfigured by hand.

- Serialise import_sources and import_config_templates into the envelope,
  with an explicit format_version; a file without one is the earlier format
  and its missing arrays are treated as empty.
- Wrap wipe + restore in withTransaction, which the service had nowhere:
  a constraint violation mid-restore used to destroy financial history with
  no rollback.
- Restore templates BEFORE sources (template_id is a foreign key), upserting
  by name and remapping template_id through the resolved ids, so restoring
  into a profile that already has templates no longer hits UNIQUE(name).
- Whitelist amount_mode and sign_convention at the import boundary with a
  readable message rather than an SQLite constraint error.

Resolves #331

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:58:52 -04:00
le king fu
c9872fc36b feat(import): recognise known bank layouts and report format drift
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
Two failure modes, both anchored on the header row.

A KNOWN BANK IS NOW READ BY NAME. `bankSignatures.ts` declares the
documented export layout of Desjardins, RBC, Banque Nationale and
Tangerine as a set of normalized header labels plus a delimiter and a
preamble quirk. The table is evaluated BEFORE the generic dictionary and
an unknown file falls straight through to it, unchanged.

The signatures are not decorative. The generic dictionary matches
keywords as substrings, one role at a time, which reads two of these
four layouts wrong — both frozen as counterfactual test pairs, same
rows, header renamed:

  - Tangerine writes `Date,Transaction,Name,Memo,Amount`. `Transaction`
    is a description keyword, so every row of the file was labelled with
    its direction word instead of the merchant.
  - RBC writes its amount column `CAD$`, which no amount keyword
    matches, next to a nearly empty `Cheque Number`. Those two are
    sparse-complementary, so the shape scan paired them as debit/credit
    and the one row carrying a cheque number imported as -247.95 instead
    of -6.95.

A signature stays a set of PREFERENCES all the same: they are written
from documented layouts, without real statements, so every hint is
dropped the moment the data contradicts it. The single exception is the
amount mode, which outranks the sparse-complementary scan — nothing
inside an RBC file can tell that pair from a genuine one — and even that
is refused unless the declared columns are candidates the shape scan
proposed. Failing degrades to the generic path; it never breaks.

FORMAT DRIFT IS NOW REPORTED INSTEAD OF IMPORTED. Every successful
import records the normalized labels of its header row in
`import_sources.header_signature`, as a JSON array and not a hash: the
panel has to be able to name the columns that moved. On the next import,
a header that normalizes differently opens a `FormatDriftPanel` above
the preview — column by column, `Montant : 3 -> 4` — with the two
outcomes that exist: adopt the re-detected format, or keep the stored
one. A cosmetic rename (`Montant` -> `MONTANT ($)`) normalizes
identically and says nothing.

A source whose file has no header row keeps `header_signature` NULL and
drift detection is inoperative on it. Documented, not worked around:
a signature invented from the data would fire on every import.

THE REPAIR PATH IS NOW IN THE INTERFACE, in the drift panel and beside
the preview's sign flip. `findDuplicates` matches on date AND
description AND amount, so re-importing a file "now that it reads right"
does not correct the rows already written — it doubles them, and a
flipped sign produces mirror pairs that net to zero in every report. The
only safe path is deleting the faulty import from the history first.

The drift re-detection reuses `detectFormatForFile`, so there is still
exactly one detector; the static guard on its caller count moves from
two to three deliberately. Its score and bank badge are dropped straight
after: they measure the format the panel offers, not the one in use.

Resolves #330
2026-08-13 14:54:46 -04:00
le king fu
ce19efd476 feat(import): make the preview mandatory and show what the amounts mean
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
A confidence score reports how many rows were READ, never what they say:
the `all-positive` fixture scores a perfect 100 % while every credit is
imported as an expense, because each of those rows is perfectly readable.
Nothing between that score and the database looked at the signs — the
preview was an optional modal of 20 rows with no totals, and the final
confirmation listed the delimiter and the date format but neither the
amount mode nor the sign convention.

The `file-preview` step had been declared in `ImportWizardStep` since the
beginning and no dispatch ever aimed at it. It is a real step now,
traversed at every import and gated by nothing — in particular not by the
detection score, which is sign-blind by construction.

- `useImportWizard`: `parseAndPreview` parses and stops at the preview,
  replacing `parsePreview` and the `parseAndCheckDuplicates` that jumped
  straight to the duplicates ("skips preview step"); `checkDuplicates`,
  dead code until now, is the preview's next button, so the rows the user
  validated are the rows that get checked.
- `summarizeParsedRows`: the recap, pure and tested — outflows and their
  total, inflows and theirs, rows in error. Totals stay SIGNED, since
  magnitudes would hide the one thing the recap exists to expose. Computed
  over the whole file, never over the twenty rows displayed.
- `flipSignFormat` + "Inverser les signes": the correction lands on the
  CONFIGURATION, so it is persisted with the source and the next file from
  that bank reads right on its own. In debit/credit mode it swaps the two
  column indices rather than toggling a convention `mapRow` ignores there,
  where a toggle would have been inert.
- `ImportConfirmation` states the amount mode, the sign convention (in the
  mode that applies it) and the column mapping, named by header.
- `FilePreviewModal` removed: it was the redundant surface, and editing
  the table alone would have mutated a still-live copy of it.

The `all-positive` KNOWN DEFECT marker is dropped rather than deleted. Its
three original expectations still hold — an unsigned file carries no
direction and detection cannot invent one — and two cases were added: the
recap tell (six outflows, zero inflows) and the honest limit, that
flipping this particular file only produces its mirror image.

1088 vitest (1054 before), tsc and vite build clean. No DB migration.

Resolves #329
2026-08-13 14:31:49 -04:00
le king fu
bf608b9d67 feat(import): score the detected format and run detection on its own
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
Detection handed back a configuration it had never tested, and only ever
ran behind the magic-wand button. A source opened for the first time
therefore started on `defaultConfig` — `;`, `DD/MM/YYYY`, columns 0/1/2 —
plausible enough to import a whole file wrong rather than fail visibly.

`detectImportFormat` now REPLAYS what it just decided over every data row
of the file and returns the rate as a `DetectionScore`. The replay runs
`mapRow`, the same pure function `parseFilesInternal` runs at import time,
under the same column-level decimal arbitration: two mappers would be the
exact divergence this chantier removes — a score reading 100 % while the
import wrote different amounts. A test asserts the two agree row for row
on every fixture of the corpus.

The threshold is 90 %, and it only colours the banner. Below it the panel
warns and prints the detailed count ("132 of 150 rows read"); at or above
it the banner is neutral. Nothing is blocked, because a perfect score says
nothing about the SIGN of what was read — `all-positive` scores 100 %
while every credit imports as an expense — and the preview step (#329) is
the real net, traversed at every import.

Detection now also fires on its own, guarded on `!existing`: a source that
has never been configured. A source that HAS one is never re-detected, the
stored format wins. The condition is deliberately not `!restored` — a
stored format that fails to decode already reports its own error, and
detecting over it would replace that message with a silent guess.

The button and the automatic run share one `detectFormatForFile`, so the
button replays detection instead of running a second, drifting variant of
it. The score is cleared when its source changes, when the format is
edited by hand (compared through the codec, so a rename keeps it) and when
a template overwrites the format — a banner vouching for a configuration
nobody measured is the misinformation it exists to remove.

Finally, the sign-convention selector is hidden in debit/credit mode.
`mapRow` computes `credit - debit` on magnitudes there and never reads
`signConvention`, so the control changed nothing. Hidden, not reset: the
stored value is left untouched.

Tests: 1054 vitest (+20), build and cargo check green. No DB migration.
CHANGELOG and docs are centralized in link 10 of the stack per the plan.

Resolves #328
2026-08-13 14:15:23 -04:00
le king fu
7b6f063094 fix(import): read amounts with an anchored parser and a real debit/credit rule
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
Three ways an imported amount could be silently wrong, all of them passing
validation, all of them fixed here.

The two-column rule was `isNaN(credit) ? -debit : credit`, so the credit always
won. Many banks write `0,00` in the unused column rather than leaving it empty,
and `isNaN(0)` is false -- every debit of such a file imported as 0,00 and the
expense simply vanished, with no error anywhere. The rule is `credit - debit` on
magnitudes now, which needs no special case for a `0,00` cell (zero is the
identity of the subtraction) and implements the documented convention even when
an export negates its debits. A row unreadable in BOTH columns is an error
instead of a free 0,00 transaction.

`parseFrenchAmount` ended on `parseFloat`, which returns the longest valid
PREFIX instead of rejecting. Measured before the fix: `"50,00-"` -> 5000,
`"1 234,56 CR"` -> 123456, `"100,00 CAD"` -> 10000. A factor-100 error, and it
passes `isNaN`, so those rows counted as VALID everywhere downstream -- which
would have defeated the signed preview (#329), the safety net of the whole
chantier. Validation is anchored over the whole normalized string now and any
residual character yields NaN.

NaN, not a rescued magnitude, for a trailing `CR`/`DB` or currency code. Two
reasons: `CR`/`DB` carry a DIRECTION, so returning a magnitude for both would
trade a loud failure for a silent SIGN error (the D/C-indicator shape is refused
upstream by design, #328); and `"100,00 CAD"` is structurally identical to
`"2025 Montant"`, so whitelisting a trailing word to rescue the first re-blinds
`detectHeader` on the second. Two accounting forms ARE legitimate and supported:
parentheses `(50,00)` and a trailing sign `50,00-`.

The `?? 0` fallbacks read column 0 -- usually the date -- when the mapping was
incomplete. An unmapped amount column is an explicit row error now, reported
ahead of any per-row problem since it is a format error affecting every row.

`1.234` is 1234 in a French column and 1.234 in an English one, and no rule
applied to that cell ALONE can tell. `detectDecimalSeparator` arbitrates from
the decisive siblings of the column and `parseFrenchAmount` takes the verdict as
an option. Detection deliberately stays out of it: it runs before a column is
known to be an amount column at all, so the verdict is applied where the value
actually becomes a transaction.

The rule itself moves out of the hook as a pure `mapRow(raw, format)` in
`importFormat.ts`. That is what lets the corpus tests run the REAL rule -- the
hand-written mirror in `csvAutoDetect.test.ts` and the static guard pinning five
`parseFilesInternal` expressions are both deleted -- and what stops the
detection score (#328) and the signed preview (#329) each re-implementing it.

Hardening is global: the parser is shared by 11 call sites, 8 in
`csvAutoDetect.ts` and 3 in the holdings CSV import (#245), where a price cell
`150,25 CAD` used to store 15025. It is refused now and `buildDetailedLines`
raises on the empty price. An unreadable QUANTITY was worse -- coerced to 0, so a
zero-value position saved in silence; the draft keeps the offending text instead
and the existing `snapshot_priced_quantity_required` fires.

Row errors become i18n keys (`import.rowErrors.*`) rather than the raw English
literals rendered straight into the preview table, since this adds a
user-visible string. The report table also carries raw exception messages, so
both render sites resolve through `isRowErrorKey` and never feed `t()` anything
that is not ours.

Test churn, per link 1's handoff (update the expectation, drop the marker, never
delete the test): the three `#325` KNOWN DEFECT blocks in `amountParser.test.ts`
flip, plus `unused-column-zero` in `csvAutoDetect.test.ts`. One block tagged
`#328` flips too -- `header-numeric-label`, whose own comment reads "#328 adds a
lexical signal to detectHeader, and #325 anchors the parser [...] either fix
closes this". The anchored parser landed first. The other `#328` blocks
(`debit-credit-reversed`, `absolute-indicator`) and the `#329` block are
verified unchanged. CHANGELOG and docs stay centralized in the last link of the
stack, as the plan specifies.

989 vitest (963 before), tsc + vite build clean, cargo check clean. No DB
migration.

Resolves #325
2026-08-13 13:37:06 -04:00
le king fu
7a604e0e0d fix(import): persist the import format and restore it faithfully
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m48s
The root bug of this chantier. `import_sources` carried no `amount_mode` and no
`sign_convention` until v17, so restoring a configured source re-inferred the
mode from `mapping.debitAmount !== undefined` and wrote
`signConvention: "negative_expense"` outright (useImportWizard.ts:321-323). A
credit-card statement configured for positive expenses came back on the default
convention at its second import and `parseFilesInternal` negated every amount:
expenses landed as income, with no error shown anywhere. The format is a read
value now, not a guessed one.

Two types and a codec, not one composed type. The four carriers are
structurally incompatible -- `ImportSource.has_header` is declared boolean,
`ImportConfigTemplate.has_header` is a number, `SourceConfig` is camelCase on a
parsed mapping -- so the guarantee cannot come from a shared shape. It comes
from `src/utils/importFormat.ts` being the single conversion point between
`ImportFormatRow` (persisted: snake_case, mapping as JSON, `has_header`
normalized to 0/1) and `ImportFormat` (domain), and from its completeness test.

That test is enforced on two levels, and both were mutation-checked:
`FORMAT_FIELD_PAIRS` is typed `Record<keyof ImportFormat, keyof
ImportFormatRow>`, so a field added to the format fails to BUILD until it is
listed; the test then compares each codec's real output keys against that table,
so a field listed but not wired fails the TEST. Dropping `sign_convention` from
`formatToRow` -- the shape of the original bug -- fails 14 tests.

`formatFromRow` validates rather than falls back. The v17 CHECK admits
`absolute_indicator` so the third amount mode ships without another migration,
but the app cannot map one: falling through to the `single` branch would read
the wrong column for every row, and anything other than `positive_expense`
would silently mean `negative_expense`. It raises an `ImportFormatError`
carrying an i18n key, and the wizard opens on a fresh configuration so
"reconfigure this source" stays an action the user can actually take.

Also here:

- The config write moves from `checkDuplicatesInternal` to `executeImport`, so
  an import abandoned at the duplicate step leaves no configuration behind. It
  is the only write point in the hook and a guard test holds that.
- Switching amount mode prunes the abandoned mode's columns, so the mode owns
  the mapping rather than the reverse. The column the `<select>` merely displays
  is deliberately not materialized -- #325 turns an unmapped amount column into
  an explicit row error, and writing a 0 here would make it unreachable. The
  mode and the pruned mapping land in ONE state update: the panel's handlers
  each spread the same `config` prop, so two calls would see the same stale
  value.
- `template_id` is recorded and restored as provenance only, never re-read as
  format. `selectedTemplateId` is no longer blanked on every source selection.
  An acceptance test rewrites a template end to end and asserts the linked
  source reads identically, plus a non-vacuity check that the template really
  changed.
- Both template writers go through the codec too, so a new format field cannot
  reach one table and miss the other.

`parseFilesInternal` is untouched: link 1's static guard on its five pinned
expressions still passes. 39 new tests (963 vitest total, was 924), build clean,
`cargo check` clean, no migration.

Resolves #324
2026-08-13 13:16:52 -04:00
le king fu
01da65c215 docs(gating): ADR 0017 + architecture + user guide + CHANGELOG
Document the edition-gating work (#297-#301):

- ADR 0017 (accepted): tier->features matrix, signed features[] override
  fail-closed in Free (CWE-863), UI-only enforcement as an assumed GPL
  soft-paywall (server-enforced price fetching stays the only hard gate),
  non-destructive downgrade, dev-override behind an explicit Cargo
  feature (CWE-489), rejected alternatives.
- architecture.md: new 'Gating par edition' section (entitlements matrix,
  LicenseContext, useEntitlement, RequireFeature/UpsellGate, NavLock,
  profileGate, Rust side), rewritten entitlements.rs section (auto-update
  now Base+, stale 'open to free' note removed), gated routes listed in
  the routing section, hooks table updated (useLicense removed in #297 ->
  useEntitlement/useIsPremium), ADR index + header refreshed.
- guide-utilisateur.md + docs.editions.* i18n keys (FR/EN) wired into
  DocsContent: new 'Editions' section with the Free/Base/Premium table,
  unlock flow and non-destructive locking tips.
- CHANGELOG.md + CHANGELOG.fr.md: one global [Unreleased] entry listing
  the modules now gated Base (Budget, Adjustments, advanced reports,
  multi-profile, auto-update) and Premium (Balance), the visible-but-
  locked upsell with disabled 'coming soon' purchase CTA, and the
  data-preserving behaviour.

Resolves #302

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:48:55 -04:00
le king fu
b89074e6c6 feat(gating): multi-profile gate (Base+), non-destructive
A Free user keeps full access to their active profile; profiles beyond
it show a lock in ProfileSwitcher and open an upsell dialog instead of
switching. Creating a profile beyond the first is locked at the single
creation point, ProfileFormModal (reached from both ProfileSwitcher and
ProfileSelectionPage), with a race guard in handleSave covering the
license boot window. Both creation entries stay visible with a lock
(locked-not-hidden). Nothing is ever removed from profiles.json — an
upgrade to Base/Premium makes every profile reappear untouched.

- New pure predicates in src/shared/profileGate.ts
  (isProfileSwitchLocked, isProfileCreationLocked) + 10 vitest
- ProfileFormModal upsell panel reuses upsell.* keys WITHOUT UpsellGate:
  the modal also opens from ProfileSelectionPage, which renders outside
  BrowserRouter, where UpsellGate's useNavigate would throw
- UpsellGate gains an optional onNavigate callback so the
  ProfileSwitcher upsell dialog can close itself after navigation
- No lock while the license is loading (anti-flash, ready guard);
  zero new i18n keys; no DB migration

Resolves #300

Generated autonomously by /autopilot run of 2026-07-20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:24:23 -04:00
le king fu
553da0ce8c feat(gating): gate routes and Sidebar for budget, advanced reports, balance
Apply the tier gating to routes and navigation on top of the #298 UI
guard:

- App.tsx: pathless RequireFeature layout-routes grouping /balance,
  /balance/accounts, /balance/snapshot under "balance"; /reports/
  highlights|compare|category|cartes under "reports-advanced"; /budget
  under "budget"; /adjustments under "adjustments". The /reports hub and
  /reports/trends stay Free and ungated.
- NavItem gains an optional `feature?: FeatureKey`; set in NAV_ITEMS on
  budget, adjustments and balance only — NOT on reports (Free hub).
- Sidebar: local NavLock child component (hook at component top level)
  renders a lock badge only when the license is ready AND the feature is
  not allowed — no locked flash at boot; items stay clickable and lead
  to the upsell via the gated route. Tooltip/aria reuse nav.locked.
- ReportsPage hub: single useEntitlement("reports-advanced") call
  drives a `locked` badge on the 4 advanced tiles via a new additive
  HubReportNavCard `locked?` prop; the Trends tile is never locked.
- Pure contract test on NAV_ITEMS (gated trio present, reports/Free
  items ungated, exactly 3 of 9 gated).

No new i18n strings (nav.locked shipped with #298), no DB migration.
Changelog centralized in #302.

Resolves #299

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:12:51 -04:00
le king fu
554373e7d8 feat(gating): UI guard — RequireFeature + UpsellGate + i18n
All checks were successful
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m28s
Add the reusable gating guard components on top of the #297 foundation:

- UpsellGate: full locked screen (lock icon, tier title, per-feature
  description). Two CTAs: "Get <tier>" rendered VISIBLE but DISABLED with
  an "online purchase coming soon" note (per planning decision — #270 will
  activate it), and "I already have a key" navigating to /settings/users
  (LicenseCard).
- RequireFeature: renders a neutral loader while the license is not ready
  (no upsell flash at boot), then children or UpsellGate. Renders <Outlet/>
  when children are omitted so it also works as a layout route grouping
  all routes of one feature.
- requiredTierFor() pure helper in shared/entitlements.ts derives the
  minimum unlocking tier from matrix membership (not array order).
- i18n: upsell.* (title, per-feature descriptions, CTAs) + nav.locked in
  BOTH locales; tier labels reuse the existing license.editions.* keys.
- Tests: requiredTierFor mapping/minimality + upsell i18n coverage for
  every FeatureKey in fr and en (the components themselves are not
  testable without jsdom).

Resolves #298

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:05:04 -04:00
le king fu
b9e13b5bca fix(gating): keep key-validation errors out of the load lifecycle (#297)
All checks were successful
PR Check / rust (pull_request) Successful in 22m21s
PR Check / frontend (pull_request) Successful in 2m32s
A rejected submitKey dispatched the same ERROR action as a failed boot
load, so the CWE-703 retry backoff armed on it and the auto refresh
(LOAD_START) cleared the "invalid key" message ~1s after submit —
LicenseCard has no local error state, the context is the only source.

Split the state: load lifecycle (status/error, retried) vs validation
(validating/validationError, never retried). VALIDATE_ERROR leaves
status untouched, so a ready license stays ready on a typo'd key (no
`ready` regression for gating consumers) and a boot-error retry loop
keeps running through a failed validation. Reducer + initial state
exported for tests, covered by LicenseContext.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:21:18 -04:00
le king fu
fd7e053239 feat(gating): license provider + entitlements matrix + useEntitlement (#297)
All checks were successful
PR Check / rust (pull_request) Successful in 22m38s
PR Check / frontend (pull_request) Successful in 2m34s
Socle for tier-based feature gating (UI-only soft-paywall).

- LicenseContext: machine-level provider (createContext<T|null>, useReducer,
  throwing consumer hook), mounted above ProfileProvider in main.tsx so a
  profile switch (BrowserRouter key remount) does not reload the license.
  Loads edition + info once; exposes { status, edition, features, info, error,
  refresh, submitKey }. Boot-error recovery (CWE-703): neutral state + capped
  exponential-backoff retry, never the upsell.
- shared/entitlements.ts: FeatureKey (kebab-case), ENTITLEMENTS matrix, pure
  isEntitled() fail-closed in Free (CWE-863) — the features[] override is
  ignored before edition==="free" is checked.
- useEntitlement(f): { allowed, ready } (ready = status==="ready"), synchronous.
- useIsPremium + its test migrated onto the context (drops the per-call double
  invoke); LicenseCard consumes the context. useLicense.ts removed (fully
  replaced, no remaining consumers).
- services/entitlements.test.ts: matrix, features[] override, override ignored
  in Free, unknown-feature deny-all.

Resolves #297
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:05:10 -04:00
le king fu
2314a64213 feat(categories): merge custom categories into the standard taxonomy (#259)
Custom categories with no standard match were shown as read-only text in
the migration wizard and force-parented under a catch-all bucket. They now
get the same inline target picker as seeded rows: picking a standard leaf
merges the custom category — its transactions, budgets, keywords and
suppliers are reassigned to the leaf — and deactivates it. Leaving a custom
unmapped keeps the previous behaviour and never blocks the wizard.

- Reducer: RESOLVE_ROW resolves rows in both plan.rows and plan.preserved;
  the Next-button guard still counts seeded rows only.
- Writer: the rewrite mapping now includes resolved preserved rows; the
  catch-all parent is created only when a custom is left unmerged; merged
  customs are deactivated instead of re-parented (shared isResolvedTarget
  helper across the three sites).
- UI: the preserved block renders MappingRow instead of plain text.
- i18n (FR/EN) + CHANGELOG (FR/EN).

Tests: reducer (resolve a preserved custom, guard unchanged, GO_NEXT still
proceeds) + writer (reassign to the chosen leaf, soft-delete, no empty
parent when all merged, orphan-free merge of a custom parent with an
unresolved child). 836 vitest green, tsc + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:43:47 +00:00
le king fu
9f628aa9f4 refactor(categories): unify both category trees' collapse state onto useCollapsibleGroups
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>
2026-07-15 21:30:17 -04:00
le king fu
9c325e274b feat(budget): adopt multi-level collapse on the Budget grid
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>
2026-07-15 21:16:01 -04:00
le king fu
48adb3db77 feat(reports): collapse category hierarchy at every level (socle + 3 reports)
All checks were successful
PR Check / rust (pull_request) Successful in 23m0s
PR Check / frontend (pull_request) Successful in 2m30s
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>
2026-07-15 20:58:18 -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
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
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
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
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
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
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
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
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
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
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
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
le king fu
9608fd3618 feat(balance): detail-account wizard (toggle to detailed at pivot date) (#215)
Adds a light confirmation modal (DetailAccountWizard) that flips a simple
balance account to detailed entry mode: sets kind='detailed' and
detailed_since = today (local civil day, YYYY-MM-DD) via updateBalanceAccount.
Toggle-only — no title capture; per-security holdings are entered at the next
normal snapshot, where validateDetailedSnapshot requires them from the pivot on.

Entry point: a 'Détailler en titres' action in the per-row actions menu of
BalanceAccountsTable, shown only for kind==='simple' rows (replaces the disabled
'Détail / coming soon' placeholder). Past aggregated history stays frozen
read-only. The flip is one-way: the #212 service backstop rejects
detailed -> simple once holdings exist, and the UI exposes no inverse action.

Exports buildDetailToggleInput() as a pure helper for a focused unit test
(project has no jsdom harness). FR/EN i18n under balance.detailWizard.*; removed
the now-dead balance.overview.detailAction / detailComingSoon keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:55:58 -04:00
le king fu
76ddad66c9 feat(balance): per-security drill-down + latent gain in accounts table (#216)
Surface unrealized (latent) gain on the existing balance surfaces, no new
visualization (decision 2026-06-04).

Service:
- AccountLatestSnapshot gains account `kind` + `latest_snapshot_line_id`, so
  the table knows which rows are detailed and where their holdings live.
- getAccountLatentGainByLine(lineId) folds a line's holdings through the
  existing computeUnrealizedGain (shares the book_cost=0 / NULL -> N/A guard).
- rollupLatentGain(accounts): pure aggregation by asset class, by envelope
  (vehicle_type, 'none' bucket), and grand total; per-bucket % denominator is
  the known book_cost only (null when none), mirroring the aggregate guard.

Hook (useBalanceOverview):
- Prefetches per-detailed-account latent gain in parallel (failures isolated),
  exposes latentGainByAccount + latentGainRollup. Simple accounts skipped.

UI:
- BalanceAccountsTable: expandable detailed rows -> per-security value + latent
  gain %; a latent-gain column (shown only when a detailed account has one);
  a summary block aggregating latent gain by asset class / envelope.
- BalanceOverviewCard: total latent gain line (hidden without detailed accounts).
- N/A rendered (never divide-by-zero) when book_cost is NULL or 0; partial-%
  flag when some positions lack a cost basis.

Native non-CAD currency display de-scoped this round (untestable while all
securities are CAD). Modified Dietz return columns (#204) unchanged.

i18n: balance.latentGain.* in FR + EN. Focused unit tests for
getAccountLatentGainByLine and rollupLatentGain (grouping, 'none' envelope,
null-% / unknown book_cost, empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:49:57 -04:00
le king fu
4846120b0f feat(balance): multi-security snapshot entry UI + SecurityPicker (#214)
Turn the detailed-account snapshot variant into the real per-title entry
surface (building on the minimal sub-rows from #213):

- New SecurityPicker (src/components/balance/SecurityPicker.tsx): an
  autocomplete combobox over the existing balance_securities catalogue
  (loaded via listSecurities()) with inline creation. Accepts any
  normalized symbol (UPPER/TRIM) with NO live ticker validation — the
  price fetch is best-effort and separate. On pick/create it emits a
  SecurityPick {symbol, asset_type, name, isNew}; a stock/crypto toggle
  lets the user set the asset class when creating a new symbol (default
  'stock'). Built on the CategoryCombobox UI idiom (ARIA listbox,
  keyboard nav, click-outside). Pure helpers filterSecurities /
  decideCreateOption are exported and unit-tested (no jsdom harness).

- SnapshotLineRow detailed sub-rows: labeled columns
  [title (SecurityPicker), quantity, price (+ existing PriceFetchControl),
  value (qty x price, read-only), book_cost, live unrealized gain].
  Account value = displayed SUM of positions. Simple accounts unchanged.

- useSnapshotEditor: new SET_HOLDING_SECURITY action + setHoldingSecurity
  callback (atomically sets symbol + asset_type + name and drops the
  stale fetched-price attribution since the symbol changed). The
  securities catalogue is loaded in loadForDate and exposed as
  state.securities, so it refreshes after a save that creates a security.

- i18n: extended balance.snapshot.detailed.* (col.*, picker.*, book cost,
  unrealized gain) in FR + EN — no hardcoded UI text.

- CHANGELOG (EN + FR) under [Unreleased]: first user-visible surface of
  the per-title detail chain (#210-#213 were schema/service/reducer).

Build (tsc + vite) green; npm test green (613 tests, +10 SecurityPicker).

Generated autonomously by /autopilot run of 2026-06-06

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:39:42 -04:00
le king fu
737654579f feat(balance): rewrite snapshot editor reducer for holdings + dispatch on account.kind (#213)
Structural rewrite of useSnapshotEditor for N holdings per detailed account,
and switch ALL simple/detailed dispatch from category_kind to the account's
own kind. This is the state/plumbing layer; the full multi-security entry UI
(SecurityPicker, rich sub-rows) lands in #214. Simple accounts behave
identically.

Reducer state shape:
- values:   Record<accountId, string>        (simple accounts, scalar)
- holdings: Record<accountId, HoldingDraft[]> (detailed accounts, one per title)
HoldingDraft is a string-typed, editable mirror of SnapshotHoldingInput with a
stable client-side rowId for React keys. Actions: ADD_HOLDING / REMOVE_HOLDING /
SET_HOLDING_FIELD (plus existing SET_VALUE/PREFILL/RESET). The legacy priced
scalar path (SET_PRICED_FIELD / pricedValues) is removed: after migration v16
(#211) every former-priced account is kind='detailed' with one holding, so those
accounts flow through the holdings path.

Dispatch:
- LOADED hydrates detailed baskets via listHoldingsBySnapshotLine (edit) keeping
  the saved price, or getHoldingsForLatestSnapshot (new) dropping the price
  (qty-0 excluded server-side). Simple accounts keep the scalar value path.
- SnapshotLineRow / SnapshotEditor / AccountForm now gate on account.kind, not
  category_kind. category.kind survives ONLY as the suggested seed default for a
  NEW account in AccountForm.

Save: detailed accounts pass their holdings array into SnapshotLineInput.holdings
(presence marks the line detailed; value = rounded-cent SUM); simple accounts
pass a scalar value with no holdings. Blank holding rows are skipped; a partial
row throws a typed error before any DB mutation.

AccountForm: adds an entry-mode selector (defaulting to the category-mapped
kind). New accounts persist as 'simple' (CreateBalanceAccountInput carries no
kind, and the service is out of this issue's scope); converting a fresh account
to detailed + pivot date is #215. Editing locks the selector for an already-
detailed account (the detailed->simple downgrade is service-guarded).

Tests: 19 new reducer/helper unit tests (pure exports; the project has no
renderHook harness) covering ADD/REMOVE/SET_HOLDING_FIELD, LOADED-vs-PREFILL
hydration (price drop, book_cost), qty-0 already excluded upstream, the
build*Lines save builders, and the dispatch-on-account.kind regression
(detailed account under a 'simple' category).

Resolves #213.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:29:48 -04:00
le king fu
0104e9223a feat(balance): chart vehicle/class toggle + collapsible returns (#204)
All checks were successful
PR Check / rust (pull_request) Successful in 22m13s
PR Check / frontend (pull_request) Successful in 2m21s
Issue 3 of overnight-2026-06-01-bilan-axe-vehicule. Builds the tracking UI
on top of the merged data layer (#202) and input UI (#203).

- Service: getSnapshotTotalsByVehicleAndDate(range) mirrors the by-category
  aggregation with GROUP BY COALESCE(a.vehicle_type, 'none') so null-envelope
  accounts land in a single 'none' bucket (never a SQL NULL key). Add
  vehicle_type to getAccountsLatestSnapshot SELECT + type.
- useBalanceOverview: new groupAxis ('class'|'vehicle') state ORTHOGONAL to
  chartMode; loads byVehicle alongside byCategory. Default groupAxis='class'.
- BalanceEvolutionChart + BalancePage: stacked-mode sub-toggle 'Par classe
  d'actif' (default) / 'Par enveloppe'. Vehicle legend reuses the #203
  vehicleType.* labels; the 'none' bucket uses balance.vehicle.none.
- BalanceAccountsTable: 4 return columns collapsed by default with a toggle,
  persisted across sessions via userPreferenceService key balance_show_returns.
- i18n FR/EN: balance.chart.axis.{byAssetClass,byVehicle}, balance.vehicle.none,
  balance.accountsTable.toggleReturns.{show,hide} (+ axisLegend aria label).

Tests: npm run build green (0 type errors); vitest 3314 passed. Added 5
service tests for the 'none' bucket + mixed envelopes + date range.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:05:00 -04:00
le king fu
344c27ee6d feat(balance): account form vehicle field + category rename via custom_label (#203)
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m23s
- AccountForm: optional vehicle_type dropdown (account mode, 6 fiscal
  envelopes + none) wired into create/update; custom_label field (category
  mode) written to custom_label, never i18n_key.
- AccountsPage rename: writes custom_label, leaves i18n_key intact (fixes
  bug I where renaming clobbered the translation key).
- New shared renderCategoryLabel helper (+ shape adapters) applied at the 5
  sites: AccountsPage, AccountForm, SnapshotEditor, BalanceAccountsTable,
  BalancePage.
- Hide v13-deactivated seeds: useBalanceAccounts passes includeInactive=false
  (keeps #202 behavior-neutral default + tests green).
- BALANCE_VEHICLE_TYPES exported from shared/types as single source of truth
  (service reuses it).
- i18n FR+EN: vehicleType.*, category.form.customLabel*, errors.vehicle_type_invalid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:50:40 -04:00
le king fu
5861346eb3 feat(balance): data layer — vehicle_type + custom_label migrations, starters, service (#202)
All checks were successful
PR Check / rust (pull_request) Successful in 22m31s
PR Check / frontend (pull_request) Successful in 2m26s
Bilan axe véhicule (Étape 1) data foundation: separate the fiscal envelope
(now balance_accounts.vehicle_type) from the asset class (the category).

- Migration v12 (additive): add vehicle_type (fiscal enum, nullable) to
  balance_accounts + custom_label to balance_categories; backfill the envelope
  onto ex-tfsa/rrsp accounts (cash stays NULL); defensive recovery of any seed
  i18n_key overwritten by free text (bug I).
- Migration v13 (reclass, conditional/idempotent): re-link ex-tfsa/rrsp accounts
  to the `other` asset class and deactivate the two envelope seeds.
- consolidated_schema.sql: 2 new columns, 5 asset-class seeds (no tfsa/rrsp),
  CELI/REER starters re-pointed to `other` + vehicle_type (avoids NULL FK).
- Types: BalanceVehicleType, custom_label / vehicle_type / category_custom_label.
- Service: normalizeVehicleType + vehicle_type_invalid; CRUD writes the new
  columns; SELECT/JOINs read them back; STARTER_ACCOUNTS + proposeStarterAccounts
  (is_active=1 + vehicle_type) + getStarterCollisions adjusted.
- Tests: Rust chain v9→v13 (snapshot_lines identical, transfers intact, archived
  ex-tfsa covered, seeds deactivated, v13 EXISTS guard, idempotence, CHECK reject)
  + consolidated complete (5 categories, 4 starters, 0 NULL FK); TS service +
  StarterAccountsModal specs. No diff to migrations <= v11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:37:56 -04:00
a73bf2ebb0 feat(balance): audit quick wins — terminology, optional symbol, movable snapshot date (#201) 2026-05-31 21:05:13 +00:00
le king fu
4095aec453 Merge remote-tracking branch 'origin/main' into issue-187-balance-cleanup-post-184-185
All checks were successful
PR Check / rust (pull_request) Successful in 22m28s
PR Check / frontend (pull_request) Successful in 2m33s
# Conflicts:
#	CHANGELOG.fr.md
#	CHANGELOG.md
2026-05-03 19:41:55 -04:00
le king fu
a7daabdf70 refactor(balance): use useTranslation directly in BalanceOnboardingCard.Step (S5)
The internal Step component received `t: TFunction` as a prop while every
other component in the codebase calls useTranslation() directly at the
top of the function. Aligns with the majority pattern.

Suggestion S5 from PR #184 review (#187).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:29:12 -04:00