Last link of the ten-link import-format stack. Links 1-9 deliberately wrote
no changelog and no documentation, to avoid a conflict at every level of a
linear pile; this link owes all of it.
- ADR 0019 records the structuring decision: the import format is a fully
persisted value, never re-inferred. It documents the three independent paths
by which it used to be lost (hardcoded restore, header drift, data export),
and why a single codec with a completeness test closes the class rather than
a composed type -- the two carriers are structurally incompatible
(`has_header` is boolean on one and number on the other). `template_id` is
recorded as a provenance label, never re-read as format.
- `docs/architecture.md` gains a dedicated "Import CSV" section covering the
codec, the lexical detection layer and its separate dictionary module, the
bank signatures, the now-mandatory preview step, and sources/templates in
the SREF envelope. Migration v17 and its four CHECK-guarded columns are
listed in the migrations table.
- Stale counts corrected against the tree, not by arithmetic: 16 -> 17
migrations (both files), `src/components/import/` 13 -> 14, `src/utils/`
4 -> 13. Tables (20) and indexes (24) were re-measured and are NOT stale --
v17 is an ALTER TABLE only -- so they are left as they are, with the reason
written down. ADR 0018, missing from the ADR table, is added.
- The user guide and the `docs.*` keys in both locales carry the same new
import journey: automatic detection, confidence score, mandatory preview
with its signed recap, sign inversion, recognised bank formats, drift panel,
and the safe repair path.
- Both changelogs carry the same entries, translated, verified section by
section including issue references. The `public/` copies sync automatically
via `syncChangelogs()`.
1176 vitest green, build clean, cargo check clean. No DB migration.
Resolves#332
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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
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
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
Detection reasoned on the shape of the data alone, so it could not tell a debit
column from a credit one: a file laid out `Date;Description;Credit;Debit` was
mapped by position and every sign of the import came out inverted — silently,
since the total is merely negated and no aggregate check notices.
A new `headerDictionary.ts` carries the FR/EN transaction dictionary (date,
description, amount, debit, credit, balance) plus the two matching helpers,
moved out of `csvAutoDetect.ts` whose holdings tables stay untouched: `montant`
is an exclusion token there and the primary amount keyword here, so the two
tables cannot be merged. Moving the generic helpers rather than exporting them
keeps the dependency one-way.
`csvAutoDetect` puts that layer in front of the shape heuristics. Labels resolve
the debit/credit order, the date, description and single-amount columns, and
give `detectHeader` a second signal for a header row carrying a bare number.
Every hint is a preference the data can veto — a labelled date column must still
parse, a labelled balance column is never excluded if it would leave nothing to
map — and a mute file (no header row, unknown labels) falls back to the shape
heuristics unchanged.
Files pairing unsigned amounts with an adjacent D/C indicator column are now
detected and REFUSED with a dedicated message, instead of being configured as
`positive_expense` and importing every deposit as an expense. Detection reports
that reason through `detectImportFormat`; `autoDetectConfig` keeps its previous
shape for the callers that only need the configuration.
Resolves#327
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
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
"tout la Gratuite/Base" -> "tout de la Gratuite/Base", flagged as the
one user-facing correction in the /pr-review pass on PR #308.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
Document the final multi-level category collapse behavior (shipped in
#288/#289/#290) and the structural decision to persist per-profile UI
state in user_preferences rather than localStorage.
- ADR 0016 (accepted): profile-specific UI state (category collapse) lives
in the profile's own SQLite user_preferences table, not localStorage.
deleteProfile drops the .db but purges no localStorage, so a per-profile
localStorage key would be a residue surviving profile deletion, leaking
which categories a (possibly PIN-protected) profile explored. States the
boundary: profile-specific -> user_preferences; machine-global (theme,
subtotals position, Cartes period mode) -> localStorage.
- guide-utilisateur.md sections 8 (Budget) + 9 (Reports): multi-level
collapse, collapsed-by-default, "Expand all / Collapse all" button,
memory per profile.
- docs.* i18n keys (fr + en): mirror the guide additions in the in-app help
page (docs.budget + docs.reports features/tips).
- architecture.md: user_preferences line now names the 4 collapse keys,
useCollapsibleGroups cross-cutting hook note, ADR index row.
No DB migration, no behavior change (docs only). Build clean, 828 vitest.
Resolves#291
Generated autonomously by /autopilot run of 2026-07-15
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
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 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>
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>
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
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>
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
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>
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>
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>
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>
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>
Document Étape 1 of the balance audit (vehicle_type axis), already shipped
in #202/#203/#204:
- ADR 0014 (Accepted): fiscal envelope is an account attribute, the category
is a pure asset class; Étape 2 (per-security detail) explicitly out of scope.
- ADR 0012 marked Rejected (never accepted, not Superseded) + pointer to 0014.
- User guide (markdown + in-app docs.balance i18n FR/EN): optional fiscal
envelope, the two chart axes, type renaming, and the historical-reclass note.
- CHANGELOG.md + CHANGELOG.fr.md [Unreleased]: Added (envelope field, envelope
axis, collapsible returns) + Changed (asset-class category, CELI/REER reclass,
rename no longer alters translation, historical-reclass note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The single 12-card SettingsPage is replaced by a hub at /settings linking
to three thematic sub-pages mounted via a shared SettingsLayout (Outlet):
/settings SettingsHomePage (3 cards-cluster + PageHelp)
/settings/users UsersSettingsPage (Account, License, DocsContent)
/settings/data DataSettingsPage (Categories, DataManagement,
PriceFetchConsentToggle)
/settings/systems SystemsSettingsPage (Version, UpdateCard,
ChangelogContent, LogViewer)
DocsPage and ChangelogPage are extracted into reusable DocsContent /
ChangelogContent components and the standalone /docs and /changelog
routes become Navigate redirects to preserve external bookmarks and
release-note links. UpdateCard is extracted from the inline updater
block for symmetry and testability.
TokenStoreFallbackBanner is mounted once in SettingsLayout, surfacing
the OS-keychain-fallback warning across the four main routes only.
The two existing /settings/categories/{standard,migrate} sub-routes
stay flat (siblings of SettingsLayout) to keep their focused flows
free of the banner — their internal back-links now point to
/settings/data.
i18n FR/EN gain settings.{home, users, data, systems, backToHome};
docs/architecture.md and CHANGELOG{,.fr}.md updated. Pure refactor of
presentation: no new business logic, no Tauri commands, no SQL
migrations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part 1 — New profiles: seed 4 starter accounts in
consolidated_schema.sql (Compte chèque/CELI/REER/Compte
non-enregistré, currency CAD, is_active=1) right after the
balance_categories seeds. Categories resolved via SELECT subquery
on the seeded `key` values for robustness.
Part 2 — Existing profiles: StarterAccountsModal proposes the same
4 starters at first /balance visit. Default-checked checkboxes,
collision rule (case-insensitive trim name + matching category)
disables matches with a "Déjà présent" tooltip. The atomic helper
`proposeStarterAccounts` wraps the inserts in BEGIN/COMMIT (rolls
back on error). user_preferences.balance_starter_proposed records
{shown_at, accepted} so the modal never reappears, dismissed or
confirmed.
Part 3 — docs/adr/0012-balance-two-level-model.md (Proposed):
captures the future vehicles × compositions model for reflection,
no code change. Numbered 0012 because 0011 was already taken by
the providers-best-effort-yahoo ADR. Linked from architecture.md
ADR table and Bilan section.
Tests: StarterAccountsModal.test.tsx covers STARTER_ACCOUNTS shape,
getStarterCollisions (case-insensitive trim, category-scoped) and
proposeStarterAccounts (insert order, COMMIT, ROLLBACK on failure).
No render tests — mirrors the BalanceOnboardingCard pattern (no
jsdom configured).
Resolves#179
Replace empty BalanceOverviewCard with BalanceOnboardingCard showing
two steps:
1. Create an account
2. Enter a snapshot
Step 2 is grayed out until at least one account exists; the entire
card is replaced by BalanceOverviewCard once a snapshot is recorded.
Hide "+ New snapshot" button when 0 accounts (it lives inside the
overview card, which is now hidden in that state).
Improve SnapshotEditPage noAccounts copy to clarify account vs
snapshot semantics.
Resolves#178
Priced balance categories now carry an explicit `asset_type`
('stock' | 'crypto') so PriceFetchControl can route to the right
provider without symbol heuristics. ETH = Ethan Allen NYSE AND
Ethereum crypto are no longer ambiguous.
Migration v10 adds a nullable column and backfills the two seeded
priced categories (key='stock','crypto'). Legacy custom priced rows
stay NULL until the user edits the category — SnapshotLineRow hides
the price-fetch button when asset_type is NULL on a priced row, so
manual entry remains available.
Service-side validation rejects priced creation without asset_type
('asset_type_required') and rejects values outside ('stock','crypto')
('asset_type_invalid'). Simple kind coerces asset_type to NULL.
The CategoryVariant of AccountForm shows the selector only when
kind=priced, requires it on submit, and resets it on kind switch.
i18n keys added under balance.category.assetType.* (FR + EN).
Tests:
- 4 new Rust migration tests in lib.rs (column add, seed backfill,
legacy row stays NULL, CHECK rejects 'gold')
- 6 new vitest cases on createBalanceCategory + listBalanceAccounts
asserts c.asset_type AS category_asset_type in the join
- balance-flow integration test updated to pass asset_type='stock'
No new test for SnapshotLineRow render guard — project lacks
@testing-library/react + jsdom; the guard is one boolean expression
covered by manual QA per autopilot decisions in PR #167.
Fixes#169
- Adds PriceFetchConsentToggle to SettingsPage Privacy section
- Reads/writes user_preferences.price_fetching_consent for active profile
- Confirmation dialog before revoke (DELETE the key entirely so next click re-opens consent modal)
- Disabled (with notPremium tooltip) when license is not premium
- Adds deletePreference() to userPreferenceService
- Adds settings.privacy.title i18n key (FR + EN)
- 10 vitest tests covering all paths
Closes#159
- Add docs.balance.* keys (FR + EN) for the new Balance Sheet section
consumed by DocsPage (title / overview / features / steps / tips).
- Wire the new section into DocsPage.tsx SECTIONS array with a Wallet
icon, slotted between reports and settings.
- Add bilingual [Unreleased] CHANGELOG entry for #145 covering the
architecture.md updates, the 3 ADRs, the new guide section and the
i18n keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #142 / Bilan #4 — translations and changelog entries.
i18n (FR + EN):
- `balance.returns.partialTooltip`, `balance.returns.noTransfersWarning`
- `balance.accountsTable.return3m/return1y/sinceCreation/unadjusted`
(label + tooltip variants)
- `balance.transfers.linkAction` + `balance.transfers.direction.{in,out}`
- `balance.transfers.modal.*` (every modal label, including the
partial-failure summary and the per-row direction toggle)
- `balance.transfers.errors.*` (5 new typed error codes)
- `balance.evolution.transferIn/transferOut` (chart label)
- `transactions.transferIcon.tooltip/ariaLabel`
CHANGELOG (English source + French translation):
- New entry under `[Unreleased]` summarising the Modified Dietz
formula, the per-account return columns (3M / 1A / since-inception
+ unadjusted), the link-transfers modal, the transactions-page
inline icon, the typed FK error on bulk-delete paths, and the
vertical reference markers on the evolution chart. References #142.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
i18n FR/EN under balance.snapshot.* — page (titles, date label and
immutability notice, total, prefill, save/create/delete buttons),
editor (empty state), line (placeholder + a11y label), delete
(double-confirm modal copy). Five new error codes added to
balance.errors.* (snapshot_date_required, snapshot_date_taken,
snapshot_not_found, snapshot_value_invalid, snapshot_priced_unsupported).
Adds common.back so the SnapshotEditPage back arrow has a localized title.
CHANGELOG entries for #146 under [Unreleased] in both EN and FR.
Refs #146
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the FR/EN translation namespace `balance.*` covering:
- balance.accountsPage.* (page chrome, tab labels, empty states)
- balance.account.* (table fields, status badges, action labels,
full account form copy with priced/simple-aware hints)
- balance.category.* (intro text, table fields, kind labels, origin
labels, action prompts, simple-only creation form, seeded labels
for the 7 standard categories)
- balance.errors.* (one entry per BalanceErrorCode union member)
CHANGELOG.md and CHANGELOG.fr.md both gain a single entry under
`[Unreleased] / Added` summarising the schema migration v9, the new
balance.service CRUD section and the AccountsPage tabs.
Refs #138
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the pre-migration SREF backup to the user so they can roll back a
category migration without digging into the filesystem:
- 90-day dismissable banner at the top of Settings > Categories pointing to
the automatic backup (hidden once reverted, once dismissed, or past 90d).
- Permanent "Restore a backup" entry in Settings > Categories, available as
long as a migration journal exists (even past the 90-day window).
- Confirmation modal with two-step consent, red Restore button, fallback
file picker when the recorded path is missing, PIN prompt for encrypted
SREF files, full-page reload on success.
Internals:
- New `categoryRestoreService` wrapping `read_import_file` +
`importTransactionsWithCategories` with stable error codes
(file_missing, read_failed, parse_failed, wrong_envelope_type,
needs_password, wrong_password, import_failed).
- New `file_exists` Tauri command for the pre-flight presence check.
- On success: `categories_schema_version=v2` + merge `reverted_at` into
`last_categories_migration`.
- Pure `shouldShowBanner` / `isWithinBannerWindow` helpers with tests.
- FR/EN i18n keys under `settings.categoriesCard.restore*`.
- CHANGELOG entries in both locales.
Closes#122
New user-facing 3-step migration flow at /settings/categories/migrate that
allows legacy v2 profiles to opt in to the v1 IPC taxonomy.
Step 1 Discover — read-only taxonomy tree (reuses CategoryTaxonomyTree from
Livraison 1, #117).
Step 2 Simulate — 3-column dry-run table with confidence badges (high /
medium / low / needs-review), transaction preview side panel, inline target
picker for unresolved rows. The "next" button is blocked until every row is
resolved.
Step 3 Consent — checklist + optional PIN field for PIN-protected profiles +
4-step loader (backup created / verified / SQL applied / committed).
Success and error screens surface the SREF backup path and the counts of
rows migrated. Errors never leave the profile in a partial state — the new
categoryMigrationService wraps the entire SQL writeover in a
BEGIN/COMMIT/ROLLBACK atomic transaction and aborts up-front if the backup
is not present / verified.
New code:
- src/services/categoryMigrationService.ts — applyMigration(plan, backup)
atomic writer (INSERT v1 → UPDATE transactions/budgets/budget_templates/
keywords/suppliers → reparent preserved customs → deactivate v2 seed →
bump categories_schema_version=v1 → journal last_categories_migration).
- src/hooks/useCategoryMigration.ts — useReducer state machine
(discover → simulate → consent → running → success | error).
- src/hooks/useCategoryMigration.test.ts — 13 pure reducer tests.
- src/components/categories-migration/{StepDiscover,StepSimulate,StepConsent,
MappingRow,TransactionPreviewPanel}.tsx — UI per the mockup.
- src/pages/CategoriesMigrationPage.tsx — wrapper with internal router,
stepper, backup/migrate orchestration, success/error screens.
Tweaks:
- src/App.tsx — new /settings/categories/migrate route.
- src/components/settings/CategoriesCard.tsx — additional card surfacing
the migrate entry for v2 profiles only.
- src/i18n/locales/{fr,en}.json — categoriesSeed.migration.* namespace
(page / stepper / 3 steps / running / success / error / backup error codes).
- CHANGELOG.{md,fr.md} — [Unreleased] / Added entry.
Scope limits respected: no SQL migration modified, no new migration added,
no unit tests of the applyMigration writer (covered by #123 in wave 4),
no restore-backup button (#122 in wave 4), no post-migration banner (#122).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a non-destructive, dismissable banner on the Dashboard that invites
profiles still on the legacy v2 category seed to discover the new v1 IPC
taxonomy. The banner links to the standard categories guide page
(/settings/categories/standard, shipped in #117).
Visibility rules:
- Only rendered when `categories_schema_version='v2'` in `user_preferences`.
- Hidden once the user dismisses it — dismissal is persisted in the same
`user_preferences` table under the key `categories_v1_banner_dismissed`
(value '1'), so the banner never reappears across app restarts.
- New v1-seeded profiles never see it.
No DB schema change: reuses the existing key/value `user_preferences`
table via the existing `getPreference`/`setPreference` helpers. No
migration added.
i18n keys under `dashboard.categoriesBanner.*` (FR + EN).
Changelog entry added under [Unreleased] in both CHANGELOG.md and
CHANGELOG.fr.md.
Adds a read-only Settings subpage at /settings/categories/standard that
exposes the full v1 IPC category taxonomy:
- Recursive tree with per-root expand/collapse (chevron buttons), clickable
only via the disclosure caret — no destructive actions anywhere on the
page.
- Live counter banner: roots / subcategories / leaves / total, computed
from the bundled categoryTaxonomyV1 JSON.
- Accent- and case-insensitive full-text search over translated names;
matching nodes keep their ancestor chain visible, non-matching branches
are pruned from the visible tree.
- Hover tooltips (native `title`) showing i18n_key, type (income /
expense / transfer — translated) and numeric id of each node — useful
for power-users cross-referencing the consolidated schema.
- Export as PDF button that triggers window.print(); a dedicated
@media print rule in styles.css forces every branch to render fully
expanded during printing regardless of the on-screen collapse state,
and hides the toolbar / back-link.
- All labels resolve via t(node.i18n_key, { defaultValue: node.name })
to be forward-compatible with future user-created taxonomy rows that
have no i18n_key.
Also:
- New CategoriesCard in Settings that links to the page (FolderTree
icon, consistent with the userGuide / changelog card pattern).
- i18n keys added under categoriesSeed.guidePage.* and
settings.categoriesCard.* (FR + EN).
- CHANGELOG.md + CHANGELOG.fr.md updated under [Unreleased] / Added.
Route uses the English-style `/settings/categories/standard` to match
the rest of the app (/settings, /categories, /changelog, ...). The
original spec mentions a French-accented path but the existing router
is English-only; documenting here so reviewers can see the decision.
No SQL migration, no schema change, no write to the database — this
is strictly a read-only view on the TS-side taxonomy bundle.
Type-check clean (tsc --noEmit), 148/148 vitest tests pass, vite build
succeeds.