Commit graph

336 commits

Author SHA1 Message Date
le king fu
fade01d955 docs(help): fix stale help and guide texts of Budget, Reports and Transactions
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
The page help and the guide (Markdown and in-app) described a Budget split
button removed in v0.2.5, an Annual-column warning that can no longer
appear, report tabs and four report cards (the hub has five, no tabs), a
"Category Zoom" whose displayed title is "Category Analysis", and a "+"
icon on transactions (the keyword button is a tag icon, shown on
categorized rows only; the right-click menu works on every row).

Resolves #431

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:18:03 -04:00
le king fu
63d8ba7589 feat(onboarding): Transactions card suggests Add as keyword, Budget card names leaves and the Annual column
Both Transactions sentences name the context menu's "Add as keyword" and
carry the advice on the word and the boxes (D18); both Budget sentences say
only last-level rows take an amount and what the Annual column does. The
labels go through StepCardText.labels, quoted in the locale files (D11).
The keyword dialog is unchanged; a test pins that it marks the transactions
as categorized by hand, so the step still checks off through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:12:25 -04:00
le king fu
00b4319c44 test(import): pin the read-only source name of the configuration panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:06:07 -04:00
le king fu
ed3a4bb9a0 fix(import): name a source after its subfolder, read-only
The source name typed during configuration was saved, but the next import
looks the source and its imported files up by the subfolder name, so a
renamed source looked new: format guessed again, every file pre-checked,
and a second source created under the folder name.

The name field becomes a read-only SourceNameDisplay showing the subfolder
name with a hint, and executeImport writes selectedSource.folder_name on
create and update, whatever config.name holds, behind a selectedSource
guard. The import card sentence no longer asks to check the source name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:05:46 -04:00
le king fu
7d3a616312 feat(import): warn about the CSV files placed directly in the import folder
scan_import_folder now returns ImportFolderScan { sources, root_files }:
the CSV/TXT files at the root, which no source reads, are named in a
warning on the Import page (above the source list, or under the "no
source" empty state). One action sets sources and root files, RESET keeps
both. Names stay on screen, never in the Journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:59:20 -04:00
le king fu
af884ca13b feat(import): show the expected folder structure, one subfolder per account, signs at the preview
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
The Import empty state now shows an "Expected structure" box in both of its
variants (no folder, folder without sources): the parent folder to choose,
one subfolder per account, and files at its root ignored, as
scan_import_folder reads them. New ImportFolderStructure component, a nested
list with decorative icons.

The getting-started cards ask for the parent folder and one subfolder per
account instead of one per bank. The preview card says that an expense must
count among the outflows and names "Flip the signs" next to "Check
duplicates": WIZARD_ROWS rows now carry every label their sentence names.
Every Import card sentence quotes the controls it names, in French and in
English, pinned by a test that reads the locale files.

Help tips, docs.import.steps, the user guide, the QA list and architecture.md
follow; CHANGELOG entry under Changed.

Resolves #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:53:05 -04:00
le king fu
1c427a1b63 fix(budget): emit the grid rows depth-first and close subtotal blocks
buildBudgetYearRows sorted each top group by depth, so an expanded
subcategory showed at the end of its group instead of under its parent.
It now builds one block per child (lone leaf, or [subtotal, ...children]),
sorts the blocks once ("(direct)" first, then by name, leaves and
sub-groups mixed, as the Budget vs Actual report) and concatenates them;
top groups are sorted by type, sort_order and name.

reorderRows put a leaf that follows a sibling sub-group into that
sub-group's block. A row at the subtotal's depth or shallower now closes
the block, which also fixes "subtotals at the bottom" in the Budget vs
Actual and Compare reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:42:27 -04:00
le king fu
c17bf5dcb4 fix(balance): translate the best-effort badge of the price fetch button
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
PR Check — Rust / rust (pull_request) Successful in 3m58s
The small badge next to "Fetch price" on a stock rendered the literal
"best-effort" without going through t, so French users saw English.
It now reads balance.priceFetching.bestEffortBadge ("non garanti" in
French). Raised by the review of PR #435 (suggestion 4).

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:01:11 -04:00
le king fu
86eff9b7a3 fix(profiles): follow-ups of the review of PR #436
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m8s
PR Check — Rust / rust (pull_request) Successful in 3m54s
- withoutFolders no longer keeps the first word of a folder that holds
  a blank (`C:\Users\Jean Dupont\...\profile_x.db` gave
  `Jean profile_x.db`): a path starts at a recognised prefix (drive,
  `/`, `~/`, `./`, UNC, `<profile folder>`) and runs to its last
  separator, blanks included, until a line break, a double quote, a
  backtick, `;`, `|`, a `,` or `:` before a blank, a closing parenthesis
  not followed by a separator, or a blank before another path. When
  nothing ends it, the text up to the last separator of the line is
  dropped rather than a folder let through. A quote before the path is
  not part of it, so `'C:\Users\Bob\x.db'` gives `'x.db'`. Relative runs
  with no recognised prefix keep the previous fallback. No error line
  contains a `/` nor a `\`, as before.
- ADR 0021 section 7 describes that rule and its limit.
- CHANGELOG.md, CHANGELOG.fr.md: no blank line between the two Fixed /
  Corrigé bullets.

Refs #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:57:29 -04:00
le king fu
ac3a981f64 test(i18n): follow-ups of the review of PR #435
- renderCategoryLabel.test.ts passes markedT itself, its assertions in
  the marked form (`t(balance.category.cash[defaultValue=cash])`): the
  second fake `t` of the repository is gone.
- i18n-mock-guard: a fake `t` is also caught as a binding (`t = (`,
  `t = vi.fn(`) or as a `t: vi.fn(` property, and a file that mocks
  react-i18next must call `i18nMock(`, comments dropped before the
  search. Each new rule has its own case in the guard's tests; the
  binding rule found renderCategoryLabel.test.ts before its migration.
- docs/architecture.md: the "only fake t" sentence is scoped to test
  files and says the guard only refuses the forms it recognises.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:53:18 -04:00
le king fu
a1c31a1da7 test(harness): follow-ups of the review of PR #434
- useOnboardingChecklist.wiring.test.ts: a data event, then a route
  change 100 ms later, still gets its deferred re-read (two LOAD_START).
  Pins the follower's deps: with `pathname` among them, the cleanup drops
  the pending timer and the test goes red (mutation M1 of the review). The
  comment that claimed "one subscription only" now points to this test.
- hookHarness.test.ts: a dependency array that shrinks, or grows by
  `undefined`, recomputes the memo (mutation M8, length branch of
  depsChanged).
- NOT SIMULATED, in hookHarness.ts and docs/architecture.md: the dispatch
  of a useReducer keeps the reducer of the first render, where React
  applies the latest one.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:20 -04:00
le king fu
74fae140b1 fix(db): follow-ups of the review of PR #433
- close() on a getDb() handle runs as a "connection close" section and
  checks its generation: a handle kept from a previous profile rejects
  with DbConnectionClosedError instead of closing the current profile.
- closeUnlocked never rejects: the state is cleared and the generation
  bumped before close(), a failing close of the old pool is logged with
  logWarn, connectToProfile still opens the next profile and closeDb()
  resolves without a connection.
- The blocking alert names a getDb() call in a callback as a possible
  cause only: a long restore also waits 15 s.
- The generation check of the transaction handle is documented as a
  defence, not reachable today.
- Tests: the "behind the queue" alert (pins the reset of lockHolder),
  close() on a stale and on the current handle, a failing close during a
  switch and in closeDb().

Refs #408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:12 -04:00
le king fu
419b20e769 feat(profiles): name the copy of a stopped catch-up and keep one copy per profile
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m1s
PR Check — Rust / rust (pull_request) Successful in 3m56s
- ProfilePreparation.catch_up_backup carries the file name of the copy as
  soon as it exists, so the Journal error line of a catch-up that stops
  afterwards (history_write_failed, pragma_failed, apply_pending) names it.
- Error lines and the JS fallback message keep only the last segment of a
  path (withoutFolders): no error line contains a slash or a backslash.
- The copy is taken again at every catch-up, written as .tmp, synced and
  renamed over a same-name copy; after a successful catch-up only, the
  other copies of that profile are pruned (remove_backups keep argument).
- Pins: a pre-existing foreign key violation in the profile without
  category 31 (RV4, RV7) and the Journal line that reports it (RT2).
- Ignored test measuring prepare_at on 100 000 transactions at floor 6:
  306 ms in release, so STARTUP_TIMEOUT_MS stays 10 s (ADR 0021).
- Guide, docs.profiles.tips, ADR 0021, architecture and changelogs.

Resolves #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 -04:00
le king fu
3882df772b fix(startup): clear the startup error when the profile opens after the delay
The 10 s startup delay of App.tsx sets the error page while the profile
is still opening; a connection that succeeded afterwards set dbReady but
left the error, so the error page stayed. The startup logic moves to
src/utils/startupConnection.ts (startProfileConnection), which clears the
error on success and is tested with fake timers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 -04:00
le king fu
2bd347b4a0 test(i18n): guard the test sources against a local fake t
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m5s
src/__integration__/i18n-mock-guard.test.ts reads every test file of src
and fails on a useTranslation: or t: ( property (word-bounded) outside
src/test/i18nMock.ts, or on a react-i18next mock that does not go
through i18nMock. docs/architecture.md gets the convention: never a raw
t, always i18nMock.

Resolves #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:58:30 -04:00
le king fu
063f6c3d7d test(i18n): replace the local marked fake t copies with i18nMock
The 12 test files that already marked their output each carried a copy
of the same fake t. They now use the shared i18nMock; no assertion
changes. LicenseCard keeps its per-test language through a getter.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:56:51 -04:00
le king fu
f5e5897df1 test(i18n): move the raw fake t files to the marked i18nMock
The 15 test files whose fake t returned the bare key (the 12 of the
issue, plus the three category render tests added by #423) now mock
react-i18next with i18nMock. Assertions naming a displayed text are
rewritten in the marked form, t(key[options]), so a text that skipped
t fails them. Components without such an assertion get one:
PriceFetchControl, PriceFetchConsentToggle (rendered through the hook
harness, its loading slot preset), CategoryDetailPanel, SourceList and
SourceCard, and the region label of OnboardingChecklist.

Each component was proved by a mutation removing one t call: every one
turns at least one test red (exit 1).

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:56:27 -04:00
le king fu
69ef92e65f test(i18n): add the shared marked fake t and use it in useLicenseMachines
src/test/i18nMock.ts holds the one fake t of the suite: markedT renders
t(key) or t(key[a=1,b=2]), and i18nMock({ language?, fixture? }) builds
the react-i18next module for vi.mock. The local echo of
useLicenseMachines.test.ts is replaced by markedT.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:49:44 -04:00
le king fu
1ba3b8bae9 test: move the React stubs of six test files to the shared hook harness
useOnboardingChecklist.wiring gains three pins the old stubs could not hold:
one loader for the provider's life (refresh and the shared value keep their
identity, no re-read), 'Skip' moving the next step on the next render, and a
change of mode alone reaching the consumers. UsersSettingsPage now commits
its effects, so an effect that read the preference goes red on the
assertion rather than on 'Invalid hook call'. BudgetTable.wiring,
BudgetPage.wiring, AppShell.slot and OnboardingStepCard drop their local
slot copies; OnboardingStepCard plays StrictMode through replayEffects and
runs its effect only on a changed dependency, which now pins location.key.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:43 -04:00
le king fu
86fd496e77 test: add a shared React hook harness with its own proofs
src/test/hookHarness.ts gives the tests that call hooks and components as
plain functions one fake React with React's semantics: state initialized
once with stable setters, the reducer applied with a dispatch spy, refs kept,
memos and callbacks recomputed only on a changed dependency, effects run by
commit() only when a dependency changed (cleanups first), and every hook it
does not cover throws instead of reaching the real React. Each semantic is
proved by hookHarness.test.ts, which also checks that the module imports
React only as a type.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:36 -04:00
le king fu
8efe99aa20 test(db): release gated transactions in finally so a red assertion holds no lock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:28 -04:00
le king fu
08946e6d08 fix(db): keep the lock-free path for the transaction handle only
The global inTransaction flag let every execute/select bypass the FIFO
lock while a withTransaction callback ran, wherever it came from. The
callback now receives its own handle, which runs directly, refuses
close() and throws once the callback settled; any other call, getDb()
from a callback included, queues behind the transaction.

connectToProfile, initializeNewProfileDb and closeDb each run as one
exclusive section (close the old pool, prepare, load), and every open
or close bumps a connection generation: an operation queued for a
closed connection throws "connection closed by profile switch". An
operation waiting more than 15 s for the lock writes one Journal
warning naming the holder and since when.

findOrCreateSecurity now requires its executor: its getDb() fallback
would deadlock if called from a transaction callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:28:36 -04:00
le king fu
8898213e02 refactor(categories): follow-ups of the review of PR #428
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
Refs #423

- parentOptions.ts: say that this list is the only guard today (updateCategory
  checks neither depth nor cycle, #393 will), instead of pointing to a
  service-side validation that does not exist yet. The height walk is also
  bounded by the number of categories, so a missing cycle guard gives a wrong
  height instead of an endless loop; a test edits a member of a cycle.
- AddKeywordDialog.tsx: drop `categoriesLoaded`, which only repeated what the
  null initial category already guarantees.
- Tests: the form and the detail panel are pinned to what they hand to
  buildParentOptions (editingId, currentParentId), through a pass-through spy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 18:44:54 -04:00
le king fu
04ac2e933e fix(categories): shrink the allowed parent depth by the height of the edited sub-tree
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
Refs #423

The review of PR #428 showed that the rule only looked at direct children:
editing a root that carries grandchildren still offered the other roots as
a parent, and saving created a fourth level, which nothing guards against
in updateCategory or moveCategory. The allowed parent depth is now
MAX_PARENT_DEPTH minus the height of the edited sub-tree (0, 1 or 2), so a
category with grandchildren gets no parent at all, only its current one.

Test written first on the reviewer's probe ([1, 10->1, 100->10, 2],
editing 1: [] expected, [2] obtained before the fix).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 18:15:52 -04:00
le king fu
4bdb00402e feat(categories): type-ahead combobox for the parent and keyword category fields
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s
Fixes #423

The "Parent category" field of CategoryForm and the "Category" field of
AddKeywordDialog were native selects with no typing. Both now use
CategoryCombobox, like the category cell of the Transactions page: typed
letters filter the list, which is ordered hierarchically with indentation
and shows seed categories through their i18n_key.

- CategoryCombobox accepts a minimal structural type (CategoryLike), so
  CategoryTreeNode rows pass without conversion; sortHierarchical is
  generic over it. Callers are unchanged.
- buildParentOptions (pure, tested) keeps roots and level-1 categories,
  leaves out the edited category, and keeps only roots when the edited
  category has children. That rule alone also leaves out its descent (a
  descendant is never a root), so no separate subtree walk is needed. The
  parent currently stored is always kept so the field never looks blank.
  CategoryForm gets an editingId prop, passed by CategoryDetailPanel.
- AddKeywordDialog reads the Transactions list (active and inputable,
  decision D2) through loadKeywordCategories, keeps the pre-filled
  category only when it is in that list (reconcileInitialCategory), and
  holds the apply button until the list is in. Its two fields sit above
  the scrolling block of matches, so the open list is not clipped by the
  scrollport.
- Tests: parentOptions, keywordCategory, and static renders of both
  components. Changelogs and the user guide (with its i18n mirror).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 17:22:31 -04:00
le king fu
d1d025df8d docs: fix three comments after review of #414
Review follow-ups of PR #414.
- audit.yml points to the concurrency comment of check-rust.yml, whose
  measurement covers push and pull_request runs only: say this workflow
  runs on schedule and workflow_dispatch, which it does not cover.
- reportService.test.ts said CI runs Node 20, so no node:sqlite; CI runs
  Node 24 since #376, and the harness itself is #392's.
- The release skill said /actions/tasks ignores `limit`. Measured
  2026-10-12: only without `page` (613 rows); with `page` it pages by 30
  or by `limit`.

Refs #376

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:05:44 -04:00
le king fu
ee17f31352 docs(onboarding): the import counter, D9 and the QA cases from the stack's review
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m1s
- Guide (FR, EN, in-app keys and `.md`) and CHANGELOG: the Import card
  never says "Done." for an import that added no transaction, and the
  step waits for a file that brought one; a Fixed entry, since v0.17.0
  already ticked the step after such an import.
- CHANGELOG: the Free edition shows the Budget step locked, it does not
  lack one.
- architecture.md: the import counter's query, and D9's flag read
  outside any transaction, its log line written after the COMMIT.
- ADR 0022: `applyMigration` and `deleteTransaction` named among the
  writes that do not notify, with their reason.
- QA list (44 cases): the noop import at rest, the focus ring on the
  highlighted control, "Skip" on the folder step without a folder, the
  Transactions and Budget cards on a profile without transactions, the
  focus after "Hide" and "Close", the doubled counter of the unfolded
  list, and a month-agnostic note on the off-screen cell.

Reviews of #402 to #407.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00
le king fu
abc9076d23 fix(budget): drop the grid's target once the grid leaves the page
On a profile without transactions whose import steps were skipped, the
rows can land before the transaction count, both reads queueing behind
the DB lock: the grid mounted, reported "Expand all", then gave way to
the empty state, and the card kept "Click Expand all…" above it with no
grid on the page. The page now passes no target while it shows the
empty state, and a wiring test replays that sequence. Invisible in dev,
where StrictMode reorders the two reads.

The month test sets the clock to 23:00 on December 31, which is still
December in UTC: under the CI runner's zone, a month read in UTC passed.
The zone is pinned west of Greenwich, as in useImportWizard.test.ts.

Review of #400 (PR #406).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00
le king fu
ba71a6421d test(transactions): replay the hook's real sequence and pin the card's sentence
The reducer tests sent SET_PAGE_RESULT right after the initial state,
while the hook sends SET_LOADING then SET_ERROR(null) before each read,
and the mount effect's categories, sources and count before or after
the first page. Had any of those three actions disarmed the guided
fallback, the fallback would never run in the app, and the tests stayed
green: a test now reduces both real orders.

The page tests now also let the list's filters move, as a later
dispatch would: after the fallback the card names every transaction,
and an ordinary visit with "Uncategorized" picked by hand names the
uncategorized ones. The sentence follows the list, not the navigation
state.

Review of #399 (PR #405).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00
le king fu
74a6572475 fix(onboarding): give the focus ring back to the highlighted control
The ring of `[data-onboarding-highlight="true"]` is an author outline:
it wins over the browser's focus ring, so a keyboard user who reached
the highlighted control saw nothing change (WCAG 2.4.7, failure F78).
Measured by the review in headless Chromium, WebView2's engine: the
highlighted button kept `solid 3px` focused or not. `outline: revert`
under `:focus-visible` gives the browser's ring back, kept as is in the
built stylesheet; the contrast test reads the rule from the stylesheet.

Review of #398 (PR #404).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00
le king fu
ec3b5d5185 test(data): pin each restore's notification after the DB lock
ADR 0022 wants a transactional write to notify once `withTransaction`
has returned, never from its callback. Only the categorization test
checked the lock; the restore tests checked that the notification
followed the COMMIT, which a notification sent from inside the callback
also satisfies. Each restore mode now asserts the lock was released.

Review of #396 (PR #402).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00
le king fu
696b83fb10 fix(onboarding): skip the D9 transaction once its flag is set
`initializeGuidedPath` opened BEGIN, read the flag, then COMMIT at every
mount of the provider: start-up, and every profile switch, creation or
deletion. While a `withTransaction` callback runs, `db.ts` lets every
other query bypass its lock, the overlap its serialization exists to
prevent.

The flag is now read first, outside any transaction, through the queued
connection: once D9 ran, an opening costs that one read. Without it, the
transaction re-reads the flag before writing, so two first loads still
cannot apply the rule twice (CWE-362).

Pinned: no transaction and no write with the flag set; the flag counts
only for "1", on both reads; a first load that sets the flag between
the two reads wins; the in-transaction reads still follow BEGIN.

Review of #396 (PR #402).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00
le king fu
a58f8b98bc fix(onboarding): count an import only once the file brought a transaction
The "Import a first file" step counted `imported_files` rows in status
`completed`. `executeImport` writes that row before its first insert, so
an import that added nothing (a file holding only its header, or one
whose rows were all in error in the preview) ticked the step: the Import
card then said "Done." at rest and sent the user to /transactions, which
had nothing to categorize and led back to the import.

The counter now keeps a completed file only when a transaction points at
it (`EXISTS` on `transactions.file_id`, covered by
`idx_transactions_file`). Replayed with sqlite3 on
`consolidated_schema.sql`: 0 after an import without a valid row, 0
before a successful import's first insert and 1 after, 0 once the file
turns to `error`. Duplicates only change nothing: their transactions
came with an earlier completed file, and a restore writes its own.

The tests run without SQLite, so the rule is pinned on the query, and
the Import page is pinned at rest after such an import: step 4 again,
on the first source card, without "Done.".

Review of #398 (PR #404), option A chosen by Max.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00
le king fu
5c18fddfc0 docs: say where the Import card holds back "Done." (self-review)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m6s
The guide and the changelog said the card never says "Done." for an
import that added no transaction. It holds it back on the report only:
on the Import page the card moves from one step to the next, and says
"Done." once the file is imported. The locale test no longer depends on
the order of the sections in the files, which DocsContent ignores.

Resolves #401

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:59:55 -04:00
le king fu
7ce5816bb2 test(docs): hold both locales to the shape the user guide reads
DocsContent and DocsSubsections read the guide straight from fr.json and
en.json, but their tests run on fixtures: a string where an array is
expected, a sub-section without its steps or a French list longer than
its English one only showed in the app. DocsContent.locales.test.ts reads
the real files: every section DocsContent renders and no other, the
strings and string arrays it reads, sub-sections that readSubsections
keeps whole, anchors of their own, French and English item for item, and
the path's sub-section in the section "Open the guide" opens.

DocsContent exports SECTIONS for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:53:52 -04:00
le king fu
b0db1d3102 docs(guide): describe the guided getting-started path in the user guide
The "Getting Started" section, which "Open the guide" opens at the end of
the path, gains a "The getting-started path" sub-section in both locales
and in docs/guide-utilisateur.md: the banner and its list, the step cards
and their outline, "Skip this step" and "Resume", the end of the path,
the profiles that find it hidden from the start, and how to show it again
from Settings -> Users. It uses the sub-section shape DocsSubsections
already reads for the Import section.

The Dashboard and Settings sections no longer describe the compact bar
the banner replaced, and the Profiles section says the path belongs to
each profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:53:47 -04:00
le king fu
2386486762 feat(onboarding): the Budget page's step card and its highlight
The budget grid opens collapsed and only its leaf rows take an amount,
so the card first points at "Expand all", then at the current month's
cell of the first editable row shown (D12). `budgetOnboardingTarget`
(pure, beside budgetTableResults.ts) decides it from the rows each
section shows, in the order it shows them; the collapse state stays
BudgetTable's own and the card never unfolds the grid.

BudgetTable rings that one control when the page asks
(`onboardingTarget`) and reports its kind through
`onOnboardingTargetChange`, in an effect, at mount and on change only:
the page needs it for the card's sentence before it asks for the ring.

`budgetCardModel(input, checklist)` gives the "collapsed" sentence for
"Expand all", the "flat" one for a cell, no sentence and no ring before
the grid says, and "Done." once the budget step is checked, which the
card needs to end the path. CARD_PAGES gets /budget. BudgetPage renders
the card first, outside the loading dim the grid gets after each save,
and asks for the ring only while the card shows. The Free edition never
mounts the page and never latches it: its path ends at 5/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:29 -04:00
le king fu
fd44d8702b feat(onboarding): the Transactions page's step card and its guided entry
The link of the "categorize by hand" step carries `guided`, which the
Transactions page reads through `readOnboardingNavState` and turns into
start filters: `useTransactions(initialFilters?)` puts them over the
defaults through `initTransactionsState`, at mount only, so the card
consuming the navigation state changes nothing. The guided entry lists
the uncategorized transactions whatever their date (the default filter
starts on January 1st, and in January the sample statements fall in the
previous year). When its first page comes back empty, the reducer drops
"uncategorized only" once: a new filters object, so the existing filter
effect reads again, and the loading placeholder meanwhile. The first
page and any filter the user sets disarm the fallback, so it never loops
and never drops a filter behind the user's back.

`transactionsCardModel(filters, checklist)` says which sentence matches
the list, names the filter by its own label, points at the first row's
category, and says "Done." once the step is checked, which the card
needs to offer the Budget step or the end of the path. The label loses
its capital through an i18next `lowercase` formatter used by the
sentence itself, `{{filter, lowercase}}`.

TransactionTable wraps every row's category in the same element and
puts the ring on the first one only, so the ring coming or going never
remounts the combobox; CategoryCombobox is unchanged. CARD_PAGES gets
/transactions, and the page renders the card first and highlights its
target only while the card shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:56:44 -04:00
le king fu
ed5eeb0265 feat(onboarding): step card with highlight, and the Import page's card
On the page of the step in progress, a card takes the place of the
banner. `StepCard` is driven by props: an h2 title that takes the focus
("Step n of N", then the step), the page's sentence, a status region
always rendered that receives "Done.", "Skip this step" until the step is
done, then "Next step" or the end of the path. `OnboardingStepCard` is
the only piece that reads the path: it renders on the latched card page
only, so the card survives its own success, focuses its title once per
arrival by "Next step" and consumes the navigation state.

The control to use carries `data-onboarding-highlight`, drawn in
styles.css as a 3px accent outline with a pulse bounded to three cycles
and none under prefers-reduced-motion; a test reads the stylesheet and
checks 3:1 against the background and the cards in both themes. The
target is passed by props to EmptyState's primary action, the first
SourceList card and WizardNavigation's next button, never queried.

`importCardModel` reads the wizard and the checklist in order: importing
before the counters, a report that added nothing as a failure, and the
"steps 3 and 4 checked" row at rest, where it can apply. CARD_PAGES gets
/import; the Import page builds the input, renders the card first and
highlights the model's target only while the card shows, and its empty
state waits for the folder to be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:34:45 -04:00
le king fu
d024f5aeb9 fix(import): mark the import folder read once its mount read is over
The wizard starts with `importFolder` null for lack of reading, which the
Import page took for "no folder": the "No import folder" empty state
flashed before the stored folder came back (#384).

`importFolderLoaded` is set in the `finally` of the mount read, after the
first scan, so neither variant of the empty state flashes, and it is kept
by RESET, which rebuilds from the initial state while the mount read
never runs again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:34:37 -04:00
le king fu
2388ad62fa feat(settings): read the getting-started path from its provider
Settings → Users no longer reads the hidden preference at mount: it
takes hidden and show from useOnboarding(), the provider being the only
writer of the path's preferences. Hiding the path from the banner of
this very page now offers "Show the getting-started checklist" at once,
and a local flag keeps the confirmation in place of the button until
the path is hidden again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:02:43 -04:00
le king fu
75c1708bbe feat(onboarding): show the getting-started banner on top of every page
The getting-started path now follows the user: AppShell mounts
OnboardingBanner at the top of <main>, in the page flow, and the
provider's mode keeps it off a hidden path, the dashboard of a profile
without transactions, a page that carries a step card and the first
load. The banner shows "Getting started n/N" once the Budget
entitlement is known, the next step linked with its navigation state,
"Show the list", which unfolds the detailed list in place, and "Hide";
at the end, the closing message, "Open the guide" and "Close". Each
route mounts its own banner slot, so a page always starts folded.

The dashboard's compact bar and the compact mode of
OnboardingChecklist are gone (D7). The detailed list reads each state
out to screen readers, shows a skipped step with its badge and
"Resume", and gives every link to a step the same navigation state
(stepNavState), with the guided entry for the manual category.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:02:39 -04:00
le king fu
21c46b6380 test(onboarding): pin the provider hook's React wiring
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
renderToStaticMarkup runs no effect, so the hook's own wiring was guarded by
no test: the route re-read (pathname in the deps), the data-channel
subscription and its cleanup, the card latch stored during render, and the
order "write the preference, then update the state" of hide, show, skip and
resume. React's hooks are stubbed so the hook runs as a plain function on a
prepared state, its effects captured and run by hand.

Resolves #396

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:39:00 -04:00
le king fu
dcf3b013dc feat(onboarding): share the getting-started path through a provider
The checklist state moves from DashboardPage to OnboardingProvider, mounted
in AppShell around the routed page; pages read it through useOnboarding().
The reducer stays in useOnboardingChecklist:

- loads are numbered and a stale answer is ignored;
- HIDE, SHOW, SKIP and RESUME win over the snapshot of a load started
  before them (#384);
- a refresh keeps the counters and the ready status, and a failed re-read
  after a first success keeps the last state; only the start of a failure
  streak is logged;
- re-read at mount, on every route change and 150 ms after a burst of
  data events.

onboardingService: getOnboardingSnapshot replaces getOnboardingCounters and
is a pure read (skipped steps included, one preference per step, filtered on
the six keys and the value "1"); initializeGuidedPath applies rule D9 once
per profile in one transaction (hidden first, then the flag, ROLLBACK on
failure), awaited once by the first load through a promise shared by
StrictMode's double mount; skipStep / resumeStep are idempotent.

computeChecklist counts skipped rows; the new pure onboardingPath module
holds nextStep, CARD_PAGES (empty for now), stepsOfPage, the card latch,
bannerMode and readOnboardingNavState. The dashboard reads the context and
keeps its compact bar for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:33:57 -04:00
le king fu
4febed9eab feat(data): announce counter-moving writes on a data-change channel
Add src/services/dataEvents.ts (ADR 0022): notifyDataChanged() and
subscribeDataChanged(), without React, on the model of logService.subscribe.
A throwing subscriber is logged and does not stop the others.

Notify after every successful write of the tables the getting-started
counters read, plus the import folder: setImportFolder, createSource,
deleteSource, createImportedFile, updateFileStatus, both import deletions,
insertBatch, updateTransactionCategory, applyKeywordWithReassignment (after
COMMIT), the four budget writes and runRestore (after COMMIT, one point for
the three restore modes). executeImport notifies once more when it ends,
succeeded or not. A failed write notifies nothing; transactional writes
notify once withTransaction has returned. applyTemplate notifies once for
the whole template through a private writeBudgetEntry helper.

Each service suite proves the notification after a success and its absence
after a failure; executeImport is driven as a plain function with React's
hooks stubbed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:33:47 -04:00
le king fu
5910387eb7 test(profiles): pin the review's quick suggestions on the catch-up (#390)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m39s
From the /pr-review of PR #394:
- the profile database opens only once prepare_profile_db has resolved
  (deferred preparation; a load started early used to pass, RT1);
- a real VACUUM INTO failure, through a dangling symlink, names the full
  path and comes out scrubbed (RV6); the test first checks the raw message
  does name the folder;
- deleting a profile removes the -journal side file, the only one this
  app produces (RV5);
- test folders are removed when each test ends, a failing one included
  (TestDir guard), instead of 42 folders left in /tmp per run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:46:26 -04:00
le king fu
02fca2ad3c docs: ADR 0021, guide and changelog for the profile catch-up (#390)
ADR 0021 records the catch-up rules and amends ADR 0003 and 0005, whose
claims about secondary profiles were false. architecture.md, CLAUDE.md
and the migration rule describe the two migration paths; the guide tells
where the pre-update copy lives and how to remove a database a deletion
on Linux left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:00 -04:00
le king fu
b0206ae56b fix(profiles): catch secondary profiles up on missed migrations (#390)
tauri-plugin-sql migrates only the database registered with add_migrations,
the default profile's. A secondary profile kept the consolidated schema of
the version that created it and never received a migration afterwards.

prepare_profile_db runs before every Database.load:
- secondary profiles catch up with the same migrations() list (pinned
  SHA-384), history kept in _sqlx_migrations; a database without history
  gets a floor inferred from its schema (the seven released creation
  states are test fixtures), so nothing at or below it is ever replayed;
- a VACUUM INTO copy is taken before the first write, synced, then renamed;
- each version runs in its own transaction with foreign keys on; one that
  fails on a foreign key is retried once without them and its orphans are
  set to NULL where the key declares ON DELETE SET NULL;
- every profile gets its TEXT manual-categorization flags repaired;
- failures are logged with file names only and the profile still opens.

New profiles are born with their full history (get_new_profile_init_sql).

The plugin keeps databases under app_config_dir(), not app_data_dir():
the same folder on Windows and macOS, not on Linux. repair_migrations,
delete_profile_db and compute_account_return looked in the wrong folder
there. profile_db_path resolves the plugin's folder and validates the
file name; deleting a profile now also removes its SQLite side files and
catch-up copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:00 -04:00
le king fu
f758282c2e fix(transactions): bind the manual-categorization flag as an integer (#390)
updateTransactionCategory bound the JS boolean `true`. tauri-plugin-sql
passes a bool to sqlx as a JSON value, so the column received the TEXT
'true', which the getting-started checklist's `is_manually_categorized = 1`
never matches: step 5 never ticked.

toSqlFlag turns a boolean, a number or a backup's string into 0/1. It is
used when a category is set by hand and when a JSON or CSV backup is
restored, since a backup exported while the bug stood carries "true"
(the CSV path read it as 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:00 -04:00
le king fu
2cbc886653 fix(license): apply the review follow-ups on the license stack head
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m47s
PR Check — Rust / rust (pull_request) Successful in 3m40s
Follow-ups of the three /pr-review passes on #386, #387 and #388, applied
on the stack head before the fast-forward merge:

- #386: pin the relay of the revoked state by revocationCheckLauncher
  (a launcher passing false instead of wasRevoked passed the whole suite),
  and say in the rate budget comment that two checks in one minute bring
  the machine-limit path to 6 requests.
- #387 S1: the fake `t` of the removal tests and of the License card tests
  marks what it translates, so a string that skipped `t` fails its test.
- #387 S2: pin that storing another key stops on Storage while the
  previous token cannot be dropped, the previous key staying.
- #387 S3: reload the license context after a failed removal too. Rust
  deletes the key first, so a token that resisted left the key gone while
  the card kept showing it under "the key was not removed".
- #388: pin that an unreadable token stays (the directory case could not
  tell, its deletion fails anyway).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:13:36 -04:00