Compare commits

..

61 commits

Author SHA1 Message Date
le king fu
4416457c22 chore: release v0.8.2
All checks were successful
Release / build-and-release (push) Successful in 24m6s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:41:48 -04:00
4f4ab87bea feat: feedback hub widget in Settings Logs card (#67)
Closes #67

Add opt-in Feedback Hub widget integrated into the Settings Logs card. Routes through a Rust command to bypass CORS and centralize privacy audit. First submission triggers a one-time consent dialog; three opt-in checkboxes (context, logs, identify with Maximus account) all unchecked by default. Wording and payload follow the cross-app conventions in la-compagnie-maximus/docs/feedback-hub-ops.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:36:26 +00:00
le king fu
3b2587d843 chore: bump version to 0.8.1
All checks were successful
Release / build-and-release (push) Successful in 24m56s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:07:53 -04:00
89b69f325e feat: new Cartes dashboard report — KPI cards, sparklines, top movers (#97)
Closes #97
2026-04-15 23:53:37 +00:00
le king fu
4c58b8bab8 feat(reports/cartes): new KPI dashboard sub-report with sparklines, top movers, budget adherence and seasonality (#97)
All checks were successful
PR Check / rust (push) Successful in 23m21s
PR Check / frontend (push) Successful in 2m24s
PR Check / rust (pull_request) Successful in 23m12s
PR Check / frontend (pull_request) Successful in 2m20s
New /reports/cartes page surfaces a dashboard-style snapshot of the
reference month:

- 4 KPI cards (income / expenses / net / savings rate) showing MoM and
  YoY deltas simultaneously, each with a 13-month sparkline highlighting
  the reference month
- 12-month income vs expenses overlay chart (bars + net balance line)
- Top 5 category increases + top 5 decreases MoM, clickable through to
  the category zoom report
- Budget adherence card: on-target count + 3 worst overruns with
  progress bars
- Seasonality card: reference month vs same calendar month averaged
  over the two previous years, with deviation indicator

All data is fetched in a single getCartesSnapshot() service call that
runs four queries concurrently (25-month flow, MoM category deltas,
budget-vs-actual, seasonality). Missing months are filled with zeroes
in the sparklines but preserved as null in the MoM/YoY deltas so the UI
can distinguish "no data" from "zero spend".

- Exported pure helpers: shiftMonth, defaultCartesReferencePeriod
- 13 vitest cases covering zero data, MoM/YoY computation, January
  wrap-around, missing-month handling, division by zero for the
  savings rate, seasonality with and without history, top mover sign
  splitting and 5-cap

Note: src/components/reports/CompareReferenceMonthPicker.tsx is a
temporary duplicate — the canonical copy lives on the issue-96 branch
(refactor: compare report). Once both branches merge the content is
identical and git will dedupe. Keeping the local copy here means the
Cartes branch builds cleanly on main without depending on #96.

Closes #97

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:44:58 -04:00
5fd2108d07 refactor: compare report — Actual-vs-actual mode with reference month picker (#96)
Closes #96
2026-04-15 23:10:23 +00:00
le king fu
4116db4090 refactor(reports/compare): unify MoM/YoY under one Actual-vs-actual mode with reference month picker (#96)
All checks were successful
PR Check / rust (push) Successful in 24m37s
PR Check / frontend (push) Successful in 2m21s
PR Check / rust (pull_request) Successful in 24m25s
PR Check / frontend (pull_request) Successful in 2m26s
Collapse the three Compare tabs (MoM / YoY / Budget) into two modes. The
new "Actual vs actual" mode exposes an explicit reference-month dropdown
in the header (defaults to the previous month, wraps around January) and
a MoM/YoY sub-toggle. The chart is rewritten to a grouped side-by-side
BarChart with two bars per category (reference period vs comparison
period) so both values are visible at a glance instead of just the
delta. The URL PeriodSelector stays in sync with the reference month.

- useCompare: state splits into { mode: "actual"|"budget", subMode:
  "mom"|"yoy", year, month }. Pure helpers previousMonth(),
  defaultReferencePeriod(), comparisonMeta() extracted for tests
- CompareModeTabs: 2 modes instead of 3
- New CompareSubModeToggle and CompareReferenceMonthPicker components
- ComparePeriodChart: grouped bars via two <Bar dataKey="..."/> on a
  vertical BarChart
- i18n: modeActual / subModeMoM / subModeYoY / referenceMonth (FR+EN),
  retire modeMoM / modeYoY
- 9 new vitest cases covering the pure helpers (January wrap-around for
  both MoM and YoY, default reference period, month/year arithmetic)

Closes #96

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:24:11 -04:00
le king fu
54cbdad710 chore: release v0.8.0
All checks were successful
Release / build-and-release (push) Successful in 25m4s
Milestone spec-refonte-rapports: reports hub + 4 sub-reports, per-domain
hooks, contextual keyword editing, category zoom with recursive CTE.
Dynamic pivot table removed. See CHANGELOG for the full list of changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:39:27 -04:00
e93b741f26 Merge pull request 'docs: polish, changelog, ADR + legacy cleanup for reports refactor (#76)' (#95) from issue-76-polish-docs into main 2026-04-14 19:35:35 +00:00
le king fu
8d5fab966a docs: polish + changelog + ADR + legacy cleanup for reports refactor (#76)
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Has been cancelled
PR Check / frontend (pull_request) Has been cancelled
- Delete legacy src/hooks/useReports.ts (the monolithic hook is now fully
  replaced by the per-domain hooks from #70)
- Delete src/components/reports/ReportFilterPanel.tsx (last caller was the
  pre-refactor ReportsPage; no longer referenced anywhere)
- Update docs/architecture.md: reports hook list now lists the 5 per-domain
  hooks, reports service entry lists every new endpoint, routing section
  lists the 4 sub-routes, categorizationService entry mentions the new
  keyword-editing helpers, components folder count + page count updated
- Update docs/guide-utilisateur.md section 9: rewrite around hub + 4
  sub-reports, explain bookmarkable period via query string, walk through
  the right-click keyword editing flow, remove stale pivot section
- Rewrite in-app docs.reports.* i18n in both FR and EN to match the new
  UX (hub, sub-reports, contextual keywords)
- New ADR docs/adr/0007-reports-hub-refactor.md: context, decision (hub +
  four routes, per-domain hooks, URL period, security guarantees on the
  keyword dialog, bounded recursive CTE for category zoom), consequences,
  alternatives considered
- CHANGELOG.md + CHANGELOG.fr.md: Unreleased entries describing the hub,
  each sub-report, contextual keyword editing, bookmarkable period, view
  mode persistence, useReports split, pivot removal, and the security
  posture of AddKeywordDialog / getCategoryZoom

Fixes #76

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:29:49 -04:00
001646db70 Merge pull request 'feat: propagate right-click add-as-keyword to transactions + highlights list (#75)' (#94) from issue-75-propagate-context-menu into main 2026-04-14 19:23:01 +00:00
le king fu
3b70abdb9e feat: propagate right-click "add as keyword" to transactions page and highlights list (#75)
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Has been cancelled
PR Check / frontend (pull_request) Has been cancelled
Wire the ContextMenu + AddKeywordDialog pair onto the remaining per-transaction
surfaces. No new business logic — pure composition of #69 / #74 pieces.

- HighlightsTopTransactionsList: optional onContextMenuRow prop, ReportsHighlightsPage
  renders ContextMenu + AddKeywordDialog on right-click
- TransactionTable: optional onRowContextMenu prop on each <tr>; TransactionsPage
  handles it and opens the dialog pre-filled with the row description + current
  category
- Aggregate tables (HighlightsTopMoversTable, ComparePeriodTable, MonthlyTrendsTable,
  CategoryOverTimeTable) are intentionally NOT wired: they show category / month
  aggregates, not individual transactions, so there is no keyword to extract from
  a row — the dialog would be nonsensical there

Fixes #75

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:15:33 -04:00
334f975deb Merge pull request 'feat: category zoom + secure AddKeywordDialog (#74)' (#93) from issue-74-zoom-add-keyword into main 2026-04-14 19:11:54 +00:00
le king fu
62430c63dc feat: category zoom + secure AddKeywordDialog with context menu (#74)
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Has been cancelled
PR Check / frontend (pull_request) Has been cancelled
Service layer
- New reportService.getCategoryZoom(categoryId, from, to, includeChildren) —
  bounded recursive CTE (WHERE ct.depth < 5) protects against parent_id cycles;
  direct-only path skips the CTE; every binding is parameterised
- Export categorizationService helpers normalizeDescription / buildKeywordRegex /
  compileKeywords so the dialog can reuse them
- New validateKeyword() enforces 2–64 char length (anti-ReDoS), whitespace-only
  rejection, returns discriminated result
- New previewKeywordMatches(keyword, limit=50) uses parameterised LIKE + regex
  filter in memory; caps candidate scan at 1000 rows to protect against
  catastrophic backtracking
- New applyKeywordWithReassignment wraps INSERT (or UPDATE-reassign) +
  per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK; rejects
  existing keyword reassignment unless allowReplaceExisting is set; never
  recategorises historical transactions beyond the ids the caller supplied

Hook
- Flesh out useCategoryZoom with reducer + fetch + refetch hook

Components (flat under src/components/reports/)
- CategoryZoomHeader — category combobox + include/direct toggle
- CategoryDonutChart — template'd from dashboard/CategoryPieChart with
  innerRadius=55 and ChartPatternDefs for SVG patterns
- CategoryEvolutionChart — AreaChart with Intl-formatted axes
- CategoryTransactionsTable — sortable table with per-row onContextMenu
  → ContextMenu → "Add as keyword" action

AddKeywordDialog — src/components/categories/AddKeywordDialog.tsx
- Lives in categories/ (not reports/) because it is a keyword-editing widget
  consumed from multiple sections
- Renders transaction descriptions as React children only (no
  dangerouslySetInnerHTML); CSS truncation (CWE-79 safe)
- Per-row checkboxes for applying recategorisation; cap visible rows at 50;
  explicit opt-in checkbox to extend to N-50 non-displayed matches
- Surfaces apply errors + "keyword already exists" replace prompt
- Re-runs category zoom fetch on success so the zoomed view updates

Page
- ReportsCategoryPage composes header + donut + evolution + transactions
  + AddKeywordDialog, fetches from useCategoryZoom, preserves query string
  for back navigation

i18n
- New keys reports.category.* and reports.keyword.* in FR + EN
- Plural forms use i18next v25 _one / _other suffixes (nMatches)

Tests
- 3 reportService tests cover bounded CTE, cycle-guard depth check, direct-only fallthrough
- New categorizationService.test.ts: 13 tests covering validation boundaries,
  parameterised LIKE preview, regex word-boundary filter, explicit BEGIN/COMMIT
  wrapping, rollback on failure, existing keyword reassignment policy
- 62 total tests passing

Fixes #74

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:09:17 -04:00
b3b832650f Merge pull request 'feat: compare report — MoM / YoY / Actual vs Budget (#73)' (#92) from issue-73-compare into main 2026-04-14 19:00:32 +00:00
le king fu
ff350d75e7 feat: compare report — MoM / YoY / budget with view toggle (#73)
Some checks failed
PR Check / rust (push) Successful in 23m24s
PR Check / frontend (push) Successful in 2m17s
PR Check / frontend (pull_request) Has been cancelled
PR Check / rust (pull_request) Has been cancelled
- Services: getCompareMonthOverMonth(year, month) and getCompareYearOverYear(year)
  return CategoryDelta[] (expense-side, ABS aggregates, parameterised SQL only)
- Shared CategoryDelta type with HighlightMover now aliased to it
- Flesh out useCompare hook: reducer + fetch + automatic year/month inference
  from the shared useReportsPeriod `to` date; budget mode skips fetch and
  delegates to CompareBudgetView which wraps the existing BudgetVsActualTable
- Components: CompareModeTabs (MoM/YoY/Budget tabs), ComparePeriodTable (sortable
  table with signed delta coloring), ComparePeriodChart (diverging horizontal
  bar chart with ChartPatternDefs for SVG patterns), CompareBudgetView
  (fetches budget rows for the current target year/month)
- ReportsComparePage wires everything with PeriodSelector + ViewModeToggle
  (storage key reports-viewmode-compare); chart/table toggle is hidden in budget
  mode since the budget table has its own presentation
- i18n keys: reports.compare.modeMoM / modeYoY / modeBudget in FR + EN
- 4 new vitest cases for the compare services: parameterised boundaries,
  January wrap-around to December previous year, delta conversion with
  previous=0 fallback to null pct, year-over-year spans

Fixes #73

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:57:13 -04:00
fae76a6b82 Merge pull request 'feat: trends report — global flow + by category (#72)' (#91) from issue-72-trends into main 2026-04-14 18:53:24 +00:00
le king fu
d06dd7a858 feat: trends report — global flow + by category with view toggle (#72)
All checks were successful
PR Check / rust (push) Successful in 23m52s
PR Check / frontend (push) Successful in 2m20s
PR Check / rust (pull_request) Successful in 23m55s
PR Check / frontend (pull_request) Successful in 2m15s
- Flesh out ReportsTrendsPage with internal subview toggle
  (global / byCategory) and ViewModeToggle (storage key reports-viewmode-trends)
- Reuse existing MonthlyTrendsChart/Table and CategoryOverTimeChart/Table
  without modification; wire them through useTrends + useReportsPeriod so the
  URL period is respected
- Add reports.trends.subviewGlobal / subviewByCategory keys in FR + EN

Fixes #72

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:52:34 -04:00
5d206d5faf Merge pull request 'feat: reports hub + highlights panel + detailed highlights page (#71)' (#90) from issue-71-highlights-hub into main 2026-04-14 18:50:40 +00:00
le king fu
ac9c8afc4a feat: reports hub with highlights panel and detailed highlights page (#71)
All checks were successful
PR Check / rust (pull_request) Successful in 24m54s
PR Check / frontend (pull_request) Successful in 2m32s
PR Check / rust (push) Successful in 24m14s
PR Check / frontend (push) Successful in 2m26s
- Transform /reports into a hub: highlights panel + 4 nav cards
- New service: reportService.getHighlights (parameterised SQL, deterministic
  via referenceDate argument for tests, computes current-month balance, YTD,
  12-month sparkline series, top expense movers vs previous month, top recent
  transactions within configurable 30/60/90 day window)
- Extended types: HighlightsData, HighlightMover, MonthBalance
- Wired useHighlights hook with reducer + window-days state
- Hub tiles (flat naming under src/components/reports):
  HubNetBalanceTile, HubTopMoversTile, HubTopTransactionsTile,
  HubHighlightsPanel, HubReportNavCard
- Detailed ReportsHighlightsPage: balance tiles, sortable top movers table,
  diverging bar chart (Recharts + patterns SVG), top transactions list with
  30/60/90 window toggle; ViewModeToggle persistence keyed as
  reports-viewmode-highlights
- New i18n keys: reports.hub.*, reports.highlights.*
- 5 new vitest cases: empty profile, parameterised queries, window sizing,
  delta computation, zero-previous divisor handling

Fixes #71

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:47:55 -04:00
a26d642b1b Merge pull request 'refactor: split useReports into per-domain hooks + URL period (#70)' (#89) from issue-70-hooks-per-domain into main 2026-04-14 18:40:16 +00:00
le king fu
6a6a196467 refactor: split useReports into per-domain hooks + URL-bookmarked period (#70)
All checks were successful
PR Check / rust (push) Successful in 24m38s
PR Check / frontend (push) Successful in 2m22s
PR Check / rust (pull_request) Successful in 24m56s
PR Check / frontend (pull_request) Successful in 2m31s
- New useReportsPeriod hook reads/writes period via ?from=&to=&period= URL params,
  default civil year, pure resolver exported for tests
- New per-domain hooks: useHighlights, useTrends, useCompare, useCategoryZoom
  (stubs wired to useReportsPeriod, to be fleshed out in #71-#74)
- Rewire legacy useReports to consume useReportsPeriod; keep backward-compat
  state shape (period/customDateFrom/customDateTo) so /reports tabs keep working
- Mark useReports @deprecated pending removal in #76
- Tests: 7 new cases covering resolveReportsPeriod defaults, bookmarks,
  invalid inputs, preset resolution

Fixes #70

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:37:33 -04:00
a50be5caf6 Merge pull request 'refactor: pivot removal + sub-route skeletons + shared components (#69)' (#88) from issue-69-foundation-pivot-removal into main 2026-04-14 18:33:17 +00:00
le king fu
91430e994a refactor: remove pivot report, add sub-route skeletons and shared components (#69)
All checks were successful
PR Check / rust (push) Successful in 24m21s
PR Check / frontend (push) Successful in 2m12s
PR Check / rust (pull_request) Successful in 23m5s
PR Check / frontend (pull_request) Successful in 2m16s
- Delete DynamicReport* components and pivot types (PivotConfig, PivotResult, PivotFieldId, etc.)
- Remove getDynamicReportData/getDynamicFilterValues from reportService
- Strip pivotConfig/pivotResult from useReports hook and ReportsPage
- Drop "dynamic" from ReportTab union
- Remove reports.pivot.* and reports.dynamic i18n keys in FR and EN
- Add skeletons for /reports/highlights, /trends, /compare, /category pages
- Register the 4 new sub-routes in App.tsx
- Add reports.hub, reports.viewMode, reports.empty, common.underConstruction keys
- New shared ContextMenu component with click-outside + Escape handling
- Refactor ChartContextMenu to compose generic ContextMenu
- New ViewModeToggle with localStorage persistence via storageKey
- New Sparkline (Recharts LineChart) for compact trends
- Unit tests for readViewMode helper

Fixes #69

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:25:38 -04:00
le king fu
cab4cc174a chore: release v0.7.4
All checks were successful
Release / build-and-release (push) Successful in 26m7s
Wraps up the spec-oauth-keychain milestone: OAuth tokens in OS keychain,
HMAC-signed account cache, fallback banner, and Argon2id PIN hashing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:24:40 -04:00
ba5257791f Merge pull request 'fix: migrate PIN hashing from SHA-256 to Argon2id (#54)' (#55) from fix/simpl-resultat-54-argon2id-pin into main 2026-04-14 12:49:05 +00:00
440a43683d Merge pull request 'fix(deps): bump vite to 6.4.2 (GHSA-4w7w-66w2-5vf9, GHSA-p9ff-h696-f583)' (#77) from issue-59-bump-vite into main 2026-04-14 12:29:08 +00:00
9ccfc7a9d9 Merge pull request 'docs: ADR 0006 + changelog + architecture for OAuth keychain (#82)' (#87) from issue-82-wrap-up into main 2026-04-14 12:28:07 +00:00
le king fu
65bc7f5130 docs: ADR 0006 + changelog + architecture for OAuth keychain (#82)
All checks were successful
PR Check / rust (push) Successful in 22m44s
PR Check / frontend (push) Successful in 2m19s
PR Check / rust (pull_request) Successful in 22m25s
PR Check / frontend (pull_request) Successful in 2m19s
- New ADR-0006 documenting the OS keychain migration: context,
  options considered (keyring vs stronghold vs AES-from-PIN), the
  backend choice rationale (sync-secret-service vs async-secret-
  service), anti-downgrade design, migration semantics, and the
  subscription-tampering fix via account_cache.
- architecture.md updated: new token_store / account_cache module
  entries, auth_commands descriptions now point at the keychain-
  backed API, OAuth2 + deep-link flow diagram mentions the HMAC
  step, command count bumped to 35.
- CHANGELOG.md + CHANGELOG.fr.md under Unreleased:
  - Changed: tokens moved to keychain with transparent migration
    and Settings banner on fallback.
  - Changed: account cache is now HMAC-signed.
  - Security: CWE-312 and CWE-345 explicitly closed.

Manual test matrix (pop-os + Windows) is tracked in issue #82 and
will be run by the release gatekeeper before the next tag.

Refs #66, #78, #79, #80, #81

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:26:21 -04:00
745f71782f Merge pull request 'feat: settings banner when OAuth tokens use file fallback (#81)' (#86) from issue-81-fallback-banner into main 2026-04-14 12:21:37 +00:00
le king fu
9a9d3c89b9 feat: dismissable banner with session-storage memory (#81)
All checks were successful
PR Check / rust (push) Successful in 22m28s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 22m30s
PR Check / frontend (pull_request) Successful in 2m18s
Adds a close button and session-scoped dismissal flag so the banner
can be acknowledged for the current run but reappears on the next
app launch if the fallback is still active — matches the #81
acceptance criterion.

- sessionStorage key survives page navigation within the run, is
  cleared on app restart.
- Graceful on storage quota errors.
- New `common.close` i18n key (FR: "Fermer", EN: "Close") used as
  the aria-label of the close button.
2026-04-14 08:20:20 -04:00
le king fu
3b1c41c48e feat: settings banner when OAuth tokens fall back to file store (#81)
Some checks are pending
PR Check / rust (push) Waiting to run
PR Check / frontend (push) Waiting to run
PR Check / rust (pull_request) Successful in 22m28s
PR Check / frontend (pull_request) Successful in 2m19s
Adds a visible warning in the Settings page when `token_store` has
landed in the file fallback instead of the OS keychain. Without this,
a user on a keychain-less system would silently lose the security
benefit introduced in #78 and never know.

- New `get_token_store_mode` service wrapper in authService.ts.
- New `TokenStoreFallbackBanner` component: fetches the mode on mount,
  renders nothing when mode is `keychain` or null, renders an
  amber warning card when mode is `file`.
- Mounted in SettingsPage right after AccountCard so it sits next to
  the account state the user can fix (log out + log back in once the
  keychain is available).
- i18n keys under `account.tokenStore.fallback.*` in fr/en.

Refs #66

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:18:41 -04:00
cf31666c35 Merge pull request 'feat: HMAC-verified account cache (#80)' (#85) from issue-80-subscription-integrity into main 2026-04-14 12:12:16 +00:00
le king fu
2d7d1e05d2 feat: HMAC-sign cached account info to close subscription tampering (#80)
All checks were successful
PR Check / rust (push) Successful in 26m11s
PR Check / frontend (push) Successful in 2m20s
PR Check / rust (pull_request) Successful in 22m22s
PR Check / frontend (pull_request) Successful in 2m18s
Before this change, `license_commands::check_account_edition` read
`account.json` directly and granted Premium when `subscription_status`
was `"active"`. Any local process could write that JSON and bypass
the paywall without ever touching the Logto session.

Introduce `account_cache` with:
- `save(app, &AccountInfo)` — signs the serialised AccountInfo with
  HMAC-SHA256 and writes a `{"data", "sig"}` envelope. The 32-byte
  key lives in the OS keychain (service `com.simpl.resultat`, user
  `account-hmac-key`) alongside the OAuth tokens from #78.
- `load_unverified` — accepts both signed and legacy payloads for UI
  display (name, email, picture). The license path must never use
  this.
- `load_verified` — requires a valid HMAC signature; returns None for
  legacy payloads, missing keychain, tampered data. Used by
  `check_account_edition` so Premium stays locked until the next
  token refresh re-signs the cache.
- `delete` — wipes both the file and the keychain key on logout so
  the next session generates a fresh cryptographic anchor.

`auth_commands::handle_auth_callback` and `refresh_auth_token` now
call `account_cache::save` instead of writing the file directly.
`logout` clears both stores. `get_account_info` delegates to
`load_unverified` so upgraded users see their profile immediately.

Trust boundary: the HMAC key lives in the keychain and shares its
security model with the OAuth tokens. If the keychain is unreachable,
the gating path refuses to grant Premium (fail-closed), which matches
the store_mode policy introduced in #78.

Refs #66, CWE-345

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:07:47 -04:00
b684c88d2b Merge pull request 'ci: libdbus-1-dev for keyring build, drop appimage target (#79)' (#84) from issue-79-ci-libdbus into main 2026-04-14 00:35:36 +00:00
le king fu
481018e1e3 ci: install libdbus-1-dev for keyring build, drop appimage target (#79)
All checks were successful
PR Check / rust (push) Successful in 23m16s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 21m37s
PR Check / frontend (pull_request) Successful in 2m10s
The new token_store module (#78) depends on `sync-secret-service` via
`dbus-secret-service`, which in turn links to libdbus-1 at build time
through the `dbus` crate. Add `libdbus-1-dev` to:

- `check.yml` rust job (alongside the existing webkit/appindicator
  system deps), so every PR run compiles the keyring backend.
- `release.yml` Linux deps step, so tagged builds link correctly.

Runtime requires `libdbus-1-3`, which is present on every desktop
Linux distro by default, so `.deb` / `.rpm` depends stay unchanged.

Also add a non-blocking `cargo audit` step to check.yml to surface
advisories across the transitive dep graph (zbus, dbus-secret-service,
etc.) without failing unrelated PRs.

Drop `appimage` from `bundle.targets` in tauri.conf.json: the release
workflow explicitly builds `--bundles deb,rpm` so AppImage was never
shipped, and its presence in the config risks a silent fallback to
plaintext token storage for anyone running `tauri build` locally
without libsecret/libdbus bundled into the AppImage. No behaviour
change for CI; follow-up to re-enable AppImage properly would need a
linuxdeploy workflow that bundles the backend.

Refs #66

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:27:14 -04:00
e331217c14 Merge pull request 'feat: OAuth token storage via OS keychain (#78)' (#83) from issue-78-token-store into main 2026-04-14 00:17:41 +00:00
le king fu
feaed4058d feat: migrate OAuth tokens to OS keychain via token_store (#78)
All checks were successful
PR Check / rust (push) Successful in 17m25s
PR Check / frontend (push) Successful in 2m31s
PR Check / rust (pull_request) Successful in 18m14s
PR Check / frontend (pull_request) Successful in 2m14s
Introduce a new token_store module that persists OAuth tokens in the OS
keychain (Credential Manager on Windows, Secret Service on Linux through
sync-secret-service + crypto-rust, both pure-Rust backends).

- Keychain service name matches the Tauri bundle identifier
  (com.simpl.resultat) so credentials are scoped to the real app
  identity.
- Transparent migration on first load: a legacy tokens.json is copied
  into the keychain, then zeroed and unlinked before removal to reduce
  refresh-token recoverability from unallocated disk blocks.
- Store-mode flag (keychain|file) persisted next to the auth dir.
  After a successful keychain write the store refuses to silently
  downgrade to the file fallback, so a subsequent failure forces
  re-authentication instead of leaking plaintext.
- New get_token_store_mode command exposes the current mode to the
  frontend so a settings banner can warn users running on the file
  fallback.
- auth_commands.rs refactored: all tokens.json read/write/delete paths
  go through token_store; check_subscription_status now uses
  token_store::load().is_some() to trigger migration even when the
  24h throttle would early-return.

Refs #66

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:41:54 -04:00
le king fu
813d29e38a fix(deps): bump vite to 6.4.2 to resolve GHSA-4w7w-66w2-5vf9 and GHSA-p9ff-h696-f583
All checks were successful
PR Check / rust (push) Successful in 17m28s
PR Check / frontend (push) Successful in 2m15s
PR Check / rust (pull_request) Successful in 17m33s
PR Check / frontend (pull_request) Successful in 2m17s
Closes #59

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:05:28 -04:00
le king fu
43c5be0c84 docs(architecture): update for v0.7.3 OAuth2 and single-instance wiring
- Bump header date/version to 2026-04-13 / v0.7.3
- Correct Tauri command count (25 → 34) and add the missing commands
- Add `auth_commands.rs` section (5 commands) and expand `license_commands.rs`
  with the 4 activation commands that already existed
- New "Plugins Tauri" section documenting init order constraints
  (single-instance must be first, deep-link before setup)
- New "OAuth2 et deep-link" section explaining the end-to-end flow,
  why single-instance is required, and why `on_open_url` is used
  instead of `app.listen()`
- Note the temporary auto-update gate opening in entitlements
- Update CI/CD: GitHub Actions → Forgejo Actions, add check.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:25:57 -04:00
le king fu
f5d74b4664 fix: use on_open_url for OAuth deep-link callback
All checks were successful
Release / build-and-release (push) Successful in 27m50s
The listener `app.listen("deep-link://new-url", ...)` did not reliably
fire when tauri-plugin-single-instance (deep-link feature) forwarded a
simpl-resultat://auth/callback URL to the running instance. The user
saw the browser complete the OAuth flow, the app regain focus, and
then sit in "loading" forever because the listener never received the
URL.

Switch to the canonical Tauri v2 API — `app.deep_link().on_open_url()`
via DeepLinkExt — which is directly coupled to the deep-link plugin
and catches URLs from both initial launch and single-instance forwards.

Also surface OAuth error responses: if the callback URL contains an
`error` parameter instead of a `code`, emit `auth-callback-error` so
the UI can show the error instead of staying stuck in "loading".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:26:17 -04:00
le king fu
f14ac3c6f8 fix: temporarily open auto-update to Free edition
All checks were successful
Release / build-and-release (push) Successful in 25m59s
The auto-update gate added in #48 requires the Base edition, but the
license server (#49) needed to grant Base does not exist yet. This
chicken-and-egg left the only current user — myself — unable to
receive the critical v0.7.1 OAuth callback fix via auto-update.

Add EDITION_FREE to the auto-update feature tiers as a temporary
measure. The gate will be restored to [BASE, PREMIUM] once paid
activation works end-to-end via the Phase 2 license server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:11:16 -04:00
le king fu
88e1fff253 fix: wire single-instance plugin for OAuth deep-link callback
All checks were successful
Release / build-and-release (push) Successful in 26m52s
The Maximus Account sign-in flow was broken in v0.7.0: clicking "Sign in"
opened Logto in the browser, but when the OAuth2 callback fired
simpl-resultat://auth/callback?code=..., the OS launched a second app
instance instead of routing the URL to the running one. The second
instance had no PKCE verifier in memory, and the original instance
never received the deep-link event, leaving it stuck in "loading".

Fix: register tauri-plugin-single-instance (with the deep-link feature)
as the first plugin. It forwards the callback URL to the existing
process, which triggers the existing deep-link://new-url listener and
completes the token exchange.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:37:09 -04:00
le king fu
93fd60bf41 chore: release v0.7.0
All checks were successful
Release / build-and-release (push) Successful in 27m50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:58:34 -04:00
le king fu
324436c0f1 fix: set Logto app ID to sr-desktop-native
Update the default LOGTO_APP_ID to match the Native App registered
in the Logto instance at auth.lacompagniemaximus.com.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:53:53 -04:00
4b42d53659 Merge pull request 'feat: Maximus Account OAuth2 + machine activation (#51, #53)' (#65) from issue-51-compte-maximus-oauth into main 2026-04-10 19:38:27 +00:00
le king fu
e314bbe1e3 fix: remove handle_auth_callback from invoke_handler
All checks were successful
PR Check / rust (push) Successful in 17m12s
PR Check / frontend (push) Successful in 2m12s
PR Check / rust (pull_request) Successful in 16m56s
PR Check / frontend (pull_request) Successful in 2m14s
The auth callback is handled exclusively via the deep-link handler in
lib.rs — exposing it as a JS-invocable command is unnecessary attack
surface. The frontend listens for auth-callback-success/error events
instead.

Plaintext token storage documented as known limitation (see #66).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:35:10 -04:00
le king fu
60b995394e fix: tighten CSP img-src, show initials instead of external avatar
Some checks are pending
PR Check / rust (push) Waiting to run
PR Check / frontend (push) Waiting to run
PR Check / rust (pull_request) Successful in 17m9s
PR Check / frontend (pull_request) Successful in 2m15s
Privacy-first: remove 'https:' from img-src CSP directive to prevent
IP leaks via external avatar URLs (Google/Gravatar). AccountCard now
shows user initials instead of loading a remote image.

Also remove .keys-temp/ from .gitignore (not relevant to this PR).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:24:52 -04:00
le king fu
4e92882724 fix: restrict last_check file perms + add useAuth to architecture docs
Some checks are pending
PR Check / rust (push) Waiting to run
PR Check / frontend (push) Waiting to run
PR Check / rust (pull_request) Successful in 17m24s
PR Check / frontend (pull_request) Successful in 2m14s
- Use write_restricted() for auth/last_check file (consistent 0600)
- Add useAuth hook to the hooks table in docs/architecture.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:14:31 -04:00
le king fu
ca3005bc0e fix: use write_restricted for account.json (0600 perms)
Some checks are pending
PR Check / rust (push) Waiting to run
PR Check / frontend (push) Waiting to run
PR Check / rust (pull_request) Successful in 17m0s
PR Check / frontend (pull_request) Successful in 2m12s
account.json contains PII and subscription_status — apply the same
restricted file permissions as tokens.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:04:05 -04:00
le king fu
9e26ad58d1 fix: use base64 crate, restrict token file perms, safer chrono_now
Some checks are pending
PR Check / rust (push) Waiting to run
PR Check / frontend (push) Waiting to run
PR Check / rust (pull_request) Successful in 17m32s
PR Check / frontend (pull_request) Successful in 2m15s
- Replace hand-rolled base64 encoder with base64::URL_SAFE_NO_PAD crate
- Set 0600 permissions on tokens.json via write_restricted() helper (Unix)
- Replace chrono_now() .unwrap() with .unwrap_or_default()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:58:10 -04:00
le king fu
be5f6a55c5 fix: URL-decode auth code + replace Mutex unwrap with map_err
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Successful in 17m21s
PR Check / frontend (pull_request) Successful in 2m21s
- extract_auth_code now URL-decodes the code parameter to handle
  percent-encoded characters from the OAuth provider
- Replace Mutex::lock().unwrap() with .lock().map_err() in start_oauth
  and handle_auth_callback to avoid panics on poisoned mutex

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:43:18 -04:00
le king fu
b53a902f11 feat: Maximus Account OAuth2 PKCE + machine activation + subscription check (#51, #53)
All checks were successful
PR Check / rust (push) Successful in 16m34s
PR Check / frontend (push) Successful in 2m14s
PR Check / rust (pull_request) Successful in 16m31s
PR Check / frontend (pull_request) Successful in 2m13s
- Add auth_commands.rs: OAuth2 PKCE flow (start_oauth, handle_auth_callback,
  refresh_auth_token, get_account_info, check_subscription_status, logout)
- Add deep-link handler in lib.rs for simpl-resultat://auth/callback
- Add AccountCard.tsx + useAuth hook + authService.ts
- Add machine activation commands (activate, deactivate, list, get_activation_status)
- Extend LicenseCard with machine management UI
- get_edition() now checks account subscription for Premium detection
- Daily subscription status check (refresh token if last check > 24h)
- Configure CSP for API/auth endpoints
- Configure tauri-plugin-deep-link for desktop
- Update i18n (FR/EN), changelogs, and architecture docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:18:51 -04:00
877ace370f Merge pull request 'feat: license system (UI card + auto-update gating) (#47, #48)' (#64) from issue-46-license-commands-entitlements into main 2026-04-10 14:28:27 +00:00
dd106a1df6 Merge pull request 'feat: gate auto-updates behind license entitlement (#48)' (#58) from issue-48-gate-auto-updates into issue-46-license-commands-entitlements
All checks were successful
PR Check / rust (push) Successful in 16m9s
PR Check / frontend (push) Successful in 2m11s
PR Check / rust (pull_request) Successful in 16m10s
PR Check / frontend (pull_request) Successful in 2m14s
2026-04-10 13:55:39 +00:00
44f98549b5 Merge pull request 'feat: license UI card in settings (#47)' (#57) from issue-47-license-ui-card into issue-46-license-commands-entitlements
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
2026-04-10 13:54:59 +00:00
le king fu
6d67ab8935 feat: gate auto-updates behind license entitlement (#48)
All checks were successful
PR Check / rust (push) Successful in 16m6s
PR Check / frontend (push) Successful in 2m15s
Both code paths that touch the updater now consult `check_entitlement`
from the Rust entitlements module before calling `check()`:

- `useUpdater.ts` adds a `notEntitled` status; on Free, the check
  short-circuits and the Settings page displays an upgrade hint instead
  of fetching update metadata.
- `ErrorPage.tsx` (recovery screen) does the same so the error path
  matches the main path; users on Free no longer see network errors when
  the updater would have run.

The gate name (`auto-update`) is the same string consumed by
`commands/entitlements.rs::FEATURE_TIERS`, so changing which tier
unlocks updates is a one-line edit in that file.

Bilingual i18n keys for the new messages are added to both `fr.json`
and `en.json`. CHANGELOG entries in both languages.
2026-04-09 15:52:59 -04:00
escouade-bot
e5be6f5a56 fix: wrap rehash updateProfile in try/catch for best-effort (#54)
All checks were successful
PR Check / rust (push) Successful in 16m33s
PR Check / frontend (push) Successful in 2m14s
PR Check / rust (pull_request) Successful in 16m33s
PR Check / frontend (pull_request) Successful in 2m15s
Both handlePinSuccess handlers (ProfileSwitcher and ProfileSelectionPage)
now catch updateProfile errors so that a failed rehash persistence does
not block switchProfile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:46:27 -04:00
escouade-bot
2f610bf10a fix: make legacy PIN rehash non-blocking in verify_pin (#54)
Replace hash_pin(pin)? with hash_pin(pin).ok() so that a rehash
failure does not propagate as an error. The user can now switch
profiles even if the Argon2id re-hashing step fails — the PIN
is still correctly verified, and the legacy hash remains until
the next successful login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:46:27 -04:00
escouade-bot
34626711eb fix: address reviewer feedback (#54)
- Add automatic re-hashing of legacy SHA-256 PINs to Argon2id on
  successful verification, returning new hash to frontend for persistence
- Use constant-time comparison (subtle::ConstantTimeEq) for both
  Argon2id and legacy SHA-256 hash verification
- Add unit tests for hash_pin, verify_pin (Argon2id and legacy paths),
  re-hashing flow, error cases, and hex encoding roundtrip
- Update frontend to handle VerifyPinResult struct and save rehashed
  PIN hash via profile update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:46:27 -04:00
escouade-bot
cea16c24ae fix: migrate PIN hashing from SHA-256 to Argon2id (#54)
Replace SHA-256 with Argon2id (m=64MiB, t=3, p=1) for PIN hashing.
Existing SHA-256 hashes are verified transparently via format detection
(argon2id: prefix). New PINs are always hashed with Argon2id.

Addresses CWE-916: Use of Password Hash With Insufficient Computational Effort.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:46:27 -04:00
108 changed files with 10165 additions and 1970 deletions

View file

@ -25,7 +25,8 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends \
curl wget git ca-certificates build-essential pkg-config \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \
libdbus-1-dev
# Node.js is required by actions/checkout and actions/cache (they
# are JavaScript actions and need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
@ -63,6 +64,16 @@ jobs:
- name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
# Informational audit of transitive dependencies. Failure does not
# block the CI (advisories can appear on unrelated crates and stall
# unrelated work); surface them in the job log so we see them on
# every PR run and can react in a follow-up.
- name: cargo audit
continue-on-error: true
run: |
cargo install --locked cargo-audit || true
cargo audit --file src-tauri/Cargo.lock || true
frontend:
runs-on: ubuntu
container: ubuntu:22.04

View file

@ -31,7 +31,7 @@ jobs:
- name: Install Linux dependencies
run: |
apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils
apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils libdbus-1-dev
- name: Install Windows cross-compile dependencies
run: |

View file

@ -2,9 +2,76 @@
## [Non publié]
## [0.8.2] - 2026-04-17
### Ajouté
- **Widget Feedback Hub** (Paramètres → Journaux) : un bouton *Envoyer un feedback* dans la carte Journaux ouvre un dialogue pour soumettre suggestions, commentaires ou rapports de bogue vers le Feedback Hub central. Un dialogue de consentement (affiché une seule fois) explique que l'envoi atteint `feedback.lacompagniemaximus.com` — une exception explicite au fonctionnement 100 % local de l'app. Trois cases à cocher opt-in (toutes décochées par défaut) : inclure le contexte de navigation (page, thème, écran, version, OS), inclure les derniers logs d'erreur, m'identifier avec mon compte Maximus. L'envoi passe par une commande Rust côté backend, donc rien ne quitte la machine tant que l'utilisateur n'a pas cliqué *Envoyer* (#67)
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
### Modifié
- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96)
## [0.8.0] - 2026-04-14
### Ajouté
- **Hub des rapports** : `/reports` devient un hub affichant un panneau de faits saillants (solde mois courant + cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes) et quatre cartes de navigation vers des sous-rapports dédiés (#69#76)
- **Rapport Faits saillants** (`/reports/highlights`) : tuiles de solde avec sparklines 12 mois, tableau triable des top mouvements, graphique en barres divergentes, liste des grosses transactions avec fenêtre 30/60/90 jours (#71)
- **Rapport Tendances** (`/reports/trends`) : bascule interne entre flux global (revenus vs dépenses) et évolution par catégorie, toggle graphique/tableau sur les deux (#72)
- **Rapport Comparables** (`/reports/compare`) : barre d'onglets pour Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget ; graphique en barres divergentes centré sur zéro pour les deux premiers modes (#73)
- **Zoom catégorie** (`/reports/category`) : analyse ciblée avec donut chart de la répartition par sous-catégorie, graphique d'évolution mensuelle en aires, et tableau filtrable des transactions (#74)
- **Édition contextuelle des mots-clés** : clic droit sur n'importe quelle ligne de transaction pour ajouter sa description comme mot-clé de catégorisation ; un dialog de prévisualisation montre toutes les transactions qui seraient recatégorisées (limitées à 50, avec checkbox explicite pour les suivantes) avant validation. Disponible sur le zoom catégorie, la liste des faits saillants, et la page Transactions principale (#74, #75)
- **Période bookmarkable** : la période des rapports vit maintenant dans l'URL (`?from=YYYY-MM-DD&to=YYYY-MM-DD`), vous pouvez copier, coller et partager le lien en conservant l'état (#70)
- **Préférence chart/table** mémorisée dans `localStorage` par section de rapport
### Modifié
- Le hook monolithique `useReports` a été splitté en hooks par domaine (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) pour que chaque sous-rapport ne possède que l'état qu'il utilise (#70)
- Le menu contextuel (clic droit) des rapports est désormais un composant générique `ContextMenu` réutilisé par le menu des graphiques existant et le nouveau dialog d'ajout de mot-clé (#69)
### Supprimé
- Le tableau croisé dynamique a été retiré. Plus de 90 % de son usage réel consistait à zoomer sur une catégorie, ce que le nouveau rapport Zoom catégorie traite mieux. L'historique git préserve l'ancienne implémentation si jamais elle doit revenir (#69)
### Sécurité
- Le nouveau `AddKeywordDialog` impose une longueur de 2 à 64 caractères sur les mots-clés utilisateurs pour empêcher les attaques ReDoS sur de grands ensembles de transactions (CWE-1333), utilise des requêtes `LIKE` paramétrées pour la prévisualisation (CWE-89), encapsule l'INSERT + les UPDATE par transaction dans une transaction SQL BEGIN/COMMIT/ROLLBACK explicite (CWE-662), affiche toutes les descriptions non-sûres via rendu React enfants (CWE-79), et ne recatégorise que les lignes explicitement cochées par l'utilisateur — jamais rétroactivement. Le remplacement d'un mot-clé existant sur une autre catégorie nécessite une confirmation explicite (#74)
- `getCategoryZoom` parcourt l'arbre des catégories via une CTE récursive **bornée** (`WHERE depth < 5`), protégeant contre les cycles `parent_id` malformés (CWE-835) (#74)
## [0.7.4] - 2026-04-14
### Modifié
- Les tokens OAuth sont maintenant stockés dans le trousseau du système d'exploitation (Credential Manager sous Windows, Secret Service sous Linux) au lieu d'un fichier JSON en clair. Les utilisateurs existants sont migrés de façon transparente au prochain rafraîchissement de session ; l'ancien fichier est écrasé avec des zéros puis supprimé. Une bannière « tokens en stockage local » apparaît dans les Paramètres si le trousseau est indisponible (#66, #78, #79, #81)
- Le cache d'informations de compte est désormais signé par HMAC avec une clé stockée dans le trousseau : modifier manuellement le champ `subscription_status` dans `account.json` ne permet plus de contourner le gating Premium (#80)
- Hachage du PIN migré de SHA-256 vers Argon2id pour résistance au brute-force (CWE-916). Les PINs SHA-256 existants sont vérifiés de façon transparente et rehachés au prochain déverrouillage réussi ; les nouveaux PINs utilisent Argon2id (#54)
### Sécurité
- Correction de CWE-312 (stockage en clair des tokens OAuth), CWE-345 (absence de vérification d'intégrité du cache d'abonnement) et CWE-916 (hachage faible du PIN). Les anciens fichiers `tokens.json` et les caches `account.json` non signés sont rejetés par le chemin de gating jusqu'à ce que le prochain rafraîchissement rétablisse un anchor de confiance dans le trousseau (#66, #54)
## [0.7.3] - 2026-04-13
### Corrigé
- Connexion Compte Maximus : le callback deep-link utilise maintenant l'API canonique Tauri v2 `on_open_url`, donc le code d'autorisation parvient bien à l'app en cours d'exécution au lieu de laisser l'interface bloquée en « chargement » (#51, #65)
- Les callbacks OAuth contenant un paramètre `error` remontent maintenant l'erreur à l'interface au lieu d'être ignorés silencieusement (#51)
## [0.7.2] - 2026-04-13
### Modifié
- Les mises à jour automatiques sont temporairement ouvertes à l'édition Gratuite en attendant que le serveur de licences (issue #49) soit en ligne. Le gating sera restauré une fois l'activation payante fonctionnelle de bout en bout (#48)
## [0.7.1] - 2026-04-13
### Corrigé
- Connexion Compte Maximus : le callback OAuth2 revient maintenant correctement dans l'instance en cours au lieu de lancer une deuxième instance et de laisser l'app d'origine bloquée en « chargement » (#51, #65)
## [0.7.0] - 2026-04-11
### Ajouté
- CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60)
- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
- Carte Compte Maximus dans les Paramètres : connexion optionnelle via OAuth2 PKCE pour les fonctionnalités Premium (#51)
- Activation de machines : activer/désactiver des machines via le serveur de licences, voir les machines activées dans la carte licence (#53)
- Vérification quotidienne de l'abonnement : rafraîchit automatiquement les infos du compte une fois par jour au lancement (#51)
### Modifié
- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48)
- La détection d'édition prend maintenant en compte l'abonnement Compte Maximus : Premium remplace Base quand l'abonnement est actif (#51)
## [0.6.7] - 2026-03-29

View file

@ -2,9 +2,76 @@
## [Unreleased]
## [0.8.2] - 2026-04-17
### Added
- **Feedback Hub widget** (Settings → Logs): a *Send feedback* button in the Logs card opens a dialog to submit suggestions, comments, or bug reports to the central Feedback Hub. A one-time consent prompt explains that submission reaches `feedback.lacompagniemaximus.com` — an explicit exception to the app's 100% local operation. Three opt-in checkboxes (all unchecked by default): include navigation context (page, theme, viewport, app version, OS), include recent error logs, identify with your Maximus account. Routed through a Rust-side command so nothing is sent unless you press *Send* (#67)
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
### Changed
- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96)
## [0.8.0] - 2026-04-14
### Added
- **Reports hub**: `/reports` is now a hub surfacing a live highlights panel (current month + YTD net balance with sparklines, top movers vs. last month, top recent transactions) and four navigation cards to dedicated sub-reports (#69#76)
- **Highlights report** (`/reports/highlights`): balance tiles with 12-month sparklines, sortable top movers table, diverging bar chart, recent transactions list with 30/60/90 day window toggle (#71)
- **Trends report** (`/reports/trends`): internal sub-view toggle between global flow (income vs. expenses) and by-category evolution, chart/table toggle on both (#72)
- **Compare report** (`/reports/compare`): tab bar for Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget; diverging bar chart centered on zero for the first two modes (#73)
- **Category zoom** (`/reports/category`): single-category drill-down with donut chart of subcategory breakdown, monthly evolution area chart, and filterable transactions table (#74)
- **Contextual keyword editing**: right-click any transaction row to add its description as a categorization keyword; a preview dialog shows every transaction that would be recategorized (capped at 50, with an opt-in checkbox for N+) before you confirm. Available on the category zoom, the highlights list, and the main transactions page (#74, #75)
- **Bookmarkable period**: the reports period now lives in the URL (`?from=YYYY-MM-DD&to=YYYY-MM-DD`), so you can copy, paste, and share the link and keep the same state (#70)
- **View mode preference** (chart vs. table) is now persisted in `localStorage` per report section
### Changed
- The legacy monolithic `useReports` hook has been split into per-domain hooks (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) so every sub-report owns only the state it needs (#70)
- Context menu on reports (right-click) is now a generic `ContextMenu` shell reused by the existing chart menu and the new keyword dialog (#69)
### Removed
- The dynamic pivot table report was removed. Over 90% of its real usage was zooming into a single category, which is better served by the new Category Zoom report. Git history preserves the old implementation if it ever needs to come back (#69)
### Security
- New `AddKeywordDialog` enforces a 264 character length bound on user keywords to prevent ReDoS on large transaction sets (CWE-1333), uses parameterized `LIKE` queries for the preview (CWE-89), wraps its INSERT + per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK transaction (CWE-662), renders all untrusted descriptions as React children (CWE-79), and recategorizes only the rows the user explicitly checked — never retroactively. Keyword reassignment across categories requires an explicit confirmation step (#74)
- `getCategoryZoom` walks the category tree through a **bounded** recursive CTE (`WHERE depth < 5`), protecting against malformed `parent_id` cycles (CWE-835) (#74)
## [0.7.4] - 2026-04-14
### Changed
- OAuth tokens are now stored in the OS keychain (Credential Manager on Windows, Secret Service on Linux) instead of a plaintext JSON file. Existing users are migrated transparently on the next sign-in refresh; the old file is zeroed and removed. A "tokens stored in plaintext fallback" banner appears in Settings if the keychain is unavailable (#66, #78, #79, #81)
- Cached account info is now HMAC-signed with a keychain-stored key: writing `subscription_status` to `account.json` manually can no longer bypass the Premium gate (#80)
- PIN hashing migrated from SHA-256 to Argon2id for brute-force resistance (CWE-916). Existing SHA-256 PINs are verified transparently and rehashed on next successful unlock; new PINs use Argon2id (#54)
### Security
- Closed CWE-312 (cleartext storage of OAuth tokens), CWE-345 (missing integrity check on the subscription cache), and CWE-916 (weak PIN hashing). Legacy `tokens.json` and legacy unsigned `account.json` caches are rejected by the gating path until the next token refresh re-establishes a keychain-anchored trust (#66, #54)
## [0.7.3] - 2026-04-13
### Fixed
- Maximus Account sign-in: the deep-link callback now uses the canonical Tauri v2 `on_open_url` API, so the auth code is properly received by the running app instead of leaving the UI stuck in "loading" (#51, #65)
- OAuth callbacks containing an `error` parameter now surface the error to the UI instead of being silently ignored (#51)
## [0.7.2] - 2026-04-13
### Changed
- Auto-updates are temporarily open to the Free edition until the license server (issue #49) is live. Gating will be restored once paid activation works end-to-end (#48)
## [0.7.1] - 2026-04-13
### Fixed
- Maximus Account sign-in: the OAuth2 callback now correctly returns to the running app instead of launching a second instance and leaving the original one stuck in "loading" (#51, #65)
## [0.7.0] - 2026-04-11
### Added
- CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60)
- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
- Maximus Account card in Settings: optional sign-in via OAuth2 PKCE for Premium features (#51)
- Machine activation: activate/deactivate machines against the license server, view activated machines in the license card (#53)
- Daily subscription status check: automatically refreshes account info once per day at launch (#51)
### Changed
- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48)
- Edition detection now considers Maximus Account subscription: Premium overrides Base when subscription is active (#51)
## [0.6.7] - 2026-03-29

View file

@ -0,0 +1,127 @@
# ADR-0006 : Stockage des tokens OAuth dans le trousseau OS
- **Date** : 2026-04-14
- **Statut** : Accepté
## Contexte
Depuis la v0.7.0, Simpl'Résultat utilise OAuth2 Authorization Code + PKCE pour authentifier les utilisateurs auprès de Logto (Compte Maximus). Les tokens résultants (`access_token`, `refresh_token`, `id_token`) étaient persistés dans `<app_data>/auth/tokens.json`, protégés uniquement par les permissions fichier (`0600` sous Unix, aucune ACL sous Windows).
Le refresh token donne une session longue durée. Le laisser en clair expose l'utilisateur à plusieurs classes d'attaques :
- Malware local tournant sous le même UID (lecture du home directory).
- Backups automatiques (home sync, backup tools) qui copient le fichier sans distinction.
- Shell non-root obtenu par l'attaquant.
- Sous Windows, absence de protection ACL rendait le fichier lisible par n'importe quel process utilisateur.
De plus, avant cette ADR, `account.json` était également en clair et son champ `subscription_status` servait au gating licence (Premium). Écrire manuellement `{"subscription_status": "active"}` dans ce fichier contournait le paywall.
## Options considérées
### Option 1 — Trousseau OS via `keyring` crate (RETENUE)
Librairie Rust `keyring` (v3.6) qui expose une API unifiée au-dessus des trousseaux natifs :
- **Windows** : Credential Manager (Win32 API, toujours présent)
- **Linux** : Secret Service via D-Bus (gnome-keyring, kwallet, keepassxc)
- **macOS** : Keychain Services (hors cible actuelle)
**Avantages** :
- Le système d'exploitation gère la clé maître (session utilisateur).
- Pas de mot de passe supplémentaire à demander à l'utilisateur.
- Support multi-plateforme natif.
**Inconvénients** :
- Sur Linux, requiert un service Secret Service actif (D-Bus + keyring daemon). Sur une session headless ou un CI sans D-Bus, il faut un fallback.
- Dépendance de build supplémentaire (`libdbus-1-dev`).
### Option 2 — `tauri-plugin-stronghold`
Chiffrement au repos avec une master password déverrouillée au démarrage.
**Rejeté** parce que :
- Casse l'UX de connexion silencieuse (refresh automatique au démarrage).
- Ajoute une saisie de passphrase à chaque lancement.
- Surface de UX plus large qu'un simple fallback keychain.
### Option 3 — Chiffrement AES-256-GCM custom avec clé dérivée du PIN
**Rejeté** parce que :
- Seulement applicable aux profils protégés par PIN (minorité).
- Les tokens OAuth doivent être lus sans interaction (refresh silencieux), donc aucune clé à demander.
- Simplement déplace le problème de la clé maître.
## Décision
**Trousseau OS via `keyring` crate, avec fallback fichier supervisé.**
### Architecture
Nouveau module `src-tauri/src/commands/token_store.rs` qui centralise le stockage :
- `save(app, &StoredTokens)` — tente le keychain, retombe sur `tokens.json` chiffré par permissions (`0600` Unix) si keychain indisponible.
- `load(app) -> Option<StoredTokens>` — lit depuis le keychain, migre un `tokens.json` résiduel à la volée.
- `delete(app)` — efface les deux stores (idempotent).
- `store_mode(app) -> Option<StoreMode>` — exposé au frontend pour afficher une bannière quand le fallback est actif.
### Choix du backend `keyring`
Le crate `keyring` v3 demande la sélection explicite des backends :
- **Linux** : `sync-secret-service` + `crypto-rust` → passe par le crate `dbus-secret-service` → crate `dbus` → lib C `libdbus-1`. Requiert `libdbus-1-dev` au build time et `libdbus-1-3` au runtime (ce dernier est universellement présent sur les distributions desktop Linux).
- **Windows** : `windows-native` → bind direct sur `windows-sys`, pas de dépendance externe.
L'option `async-secret-service` (via `zbus`, pur Rust) a été envisagée pour éviter la dépendance `libdbus-1-dev`, mais elle force une API asynchrone sur toutes les plateformes, ce qui ne matche pas le design sync de `license_commands::current_edition` (appelé depuis `check_entitlement`, sync). Le compromis accepté : un paquet apt de plus dans la CI, une API sync partout.
### Identité du trousseau
`service = "com.simpl.resultat"` (identifiant canonique de l'app dans `tauri.conf.json`), `user = "oauth-tokens"`. Ce choix aligne l'entrée keychain avec l'identité installée de l'app pour que les outils de management de credentials du système puissent la scoper correctement.
### Garde anti-downgrade
Un flag persistant `store_mode` (valeurs `keychain` ou `file`) est écrit dans `<app_data>/auth/store_mode` après chaque opération. Une fois qu'un `store_mode = keychain` a été enregistré, toute tentative ultérieure de sauvegarde qui échoue sur le keychain retourne une erreur au lieu de silenter-dégrader vers le fichier. Cela empêche un attaquant local de forcer la dégradation en bloquant temporairement D-Bus pour capturer les tokens en clair au prochain refresh.
### Migration transparente
Au premier `load()` après upgrade depuis v0.7.x, le module détecte `tokens.json` résiduel, copie son contenu dans le keychain, puis **overwrite le fichier avec des zéros + `fsync()` avant `remove_file()`**. C'est un mitigation best-effort contre la récupération des bits sur unallocated sectors (CWE-212). Pas un substitut à un disk encryption : les backups antérieurs à la migration conservent évidemment le vieux fichier. Documenté dans le CHANGELOG comme recommandation de rotation de session post-upgrade pour les utilisateurs inquiets.
### Scope : `tokens.json` migré, `account.json` signé
`account.json` **n'est pas** migré dans le keychain pour limiter le blast radius du changement et garder `write_restricted()` en place pour les fichiers non-sensibles. Toutefois, le champ `subscription_status` de ce cache servait au gating de licence Premium, ce qui créait un trou de tampering : un malware local pouvait écrire `"active"` dans le cache pour bypass le paywall sans jamais toucher le keychain.
**Corrigé via un second module `account_cache.rs`** : le cache est désormais encapsulé dans un wrapper `{"data": {...}, "sig": "<HMAC-SHA256>"}`. La clé HMAC 32 bytes est stockée dans le keychain (`user = "account-hmac-key"`), parallèlement aux tokens. Le chemin de gating (`license_commands::check_account_edition`) appelle `account_cache::load_verified` qui refuse tout payload non-signé ou avec signature invalide, et **fail-closed** (retourne None → Premium reste gated) si la clé HMAC est inaccessible.
Le chemin d'affichage UI (`get_account_info` → `load_unverified`) accepte encore les anciens payloads non-signés pour que les utilisateurs upgradés voient leur profil immédiatement. La distinction display/verified est explicite dans l'API.
## Conséquences
### Positives
- **Protection cryptographique native** : sous Windows, les tokens sont maintenant protégés par Credential Manager (DPAPI sous le capot). Sous Linux, par le keyring daemon avec une master password de session.
- **Anti-tampering du gating licence** : écrire `account.json` ne débloque plus Premium.
- **Fail-closed par défaut** : tous les chemins qui échouent sur le keychain retournent des erreurs au lieu de dégrader silencieusement.
- **Migration transparente** : zéro action utilisateur pour les upgrades depuis v0.7.x.
- **Anti-downgrade** : un attaquant ne peut pas forcer la dégradation vers le fichier pour capturer les tokens au prochain refresh.
### Négatives
- **Dépendance Linux** : `libdbus-1-dev` est requis au build time (ajouté à `check.yml` et `release.yml`). Au runtime, `libdbus-1-3` est déjà présent sur toutes les distros desktop, mais une session headless sans D-Bus déclenche le fallback fichier (signalé à l'utilisateur par la bannière Settings).
- **Surface d'attaque supply-chain accrue** : `keyring` + `dbus-secret-service` + `zbus` + `dbus` représentent une chaîne transitive nouvelle. Mitigé par un step `cargo audit` non-bloquant dans `check.yml`.
- **Logs plus verbeux** : chaque fallback imprime un warning sur stderr pour qu'un dev puisse diagnostiquer. Pas de télémétrie.
- **Sous Linux, la première utilisation peut demander un déverrouillage** : GNOME Keyring peut prompt l'utilisateur pour déverrouiller sa session keyring si elle ne l'est pas déjà. Ce comportement est natif du trousseau, pas de Simpl'Résultat.
### Tests
- Tests unitaires : 9 nouveaux tests (serde round-trip de `StoredTokens`, parse/encode de `StoreMode`, zéroïfication, HMAC sign/verify, tamper detection, wrong key, envelope serde).
- Tests manuels : matrice de 5 scénarios sur pop-os (Linux) et Windows, documentée dans l'issue #82.
- Pas de mock du keychain en CI : la matrice manuelle couvre les chemins où une lib externe est requise.
## Références
- Issue parente : maximus/simpl-resultat#66
- PRs : #83 (core), #84 (CI/packaging), #85 (subscription HMAC), #86 (UI banner), #87 (wrap-up)
- CWE-212 : Improper Removal of Sensitive Information Before Storage or Transfer
- CWE-312 : Cleartext Storage of Sensitive Information
- CWE-345 : Insufficient Verification of Data Authenticity
- CWE-757 : Selection of Less-Secure Algorithm During Negotiation
- [keyring crate v3.6 documentation](https://docs.rs/keyring/3.6.3/keyring/)
- [Secret Service API specification](https://specifications.freedesktop.org/secret-service-spec/latest/)

View file

@ -0,0 +1,95 @@
# ADR 0007 — Reports hub refactor
- Status: Accepted
- Date: 2026-04-14
- Milestone: `spec-refonte-rapports`
## Context
The original `/reports` page exposed five tabs (`trends`, `byCategory`, `overTime`, `budgetVsActual`, `dynamic`) as independent analytic views backed by a single monolithic `useReports` hook. Three problems built up over time:
1. **No narrative.** None of the tabs answered "what's important to know about my finances this month?". Users had to navigate several tabs and reconstruct the story themselves.
2. **Oversized pivot.** The dynamic pivot table (`DynamicReport*`) was powerful but complex. In practice ~90 % of its actual usage boiled down to zooming into a single category. It added visual and cognitive debt without proportional value.
3. **Disconnected classification.** Keywords could only be edited from `/categories`. Spotting a mis-classified transaction in a report meant leaving the report, editing a rule, and navigating back — a context break that discouraged hygiene.
## Decision
Refactor `/reports` into a **hub + four dedicated sub-routes**, wired to a shared bookmarkable period and per-domain hooks, with contextual keyword editing via right-click.
### Routing
```
/reports → hub (highlights panel + nav cards)
/reports/highlights → detailed highlights
/reports/trends → global flow + by-category evolution
/reports/compare → month vs month / year vs year / actual vs budget
/reports/category → single-category zoom with rollup
```
All pages share the reporting period through the URL query string (`?from=YYYY-MM-DD&to=YYYY-MM-DD&period=...`), resolved by a pure `resolveReportsPeriod()` helper. Default: current civil year. The query string approach is deliberately **not** a React context — it keeps the URL bookmarkable and stays consistent with the rest of the project, which does not use global React contexts for UI state.
### Per-domain hooks
The monolithic `useReports` was split into:
| Hook | Responsibility |
|------|----------------|
| `useReportsPeriod` | Read/write period via `useSearchParams` |
| `useHighlights` | Fetch highlights snapshot + window-days state |
| `useTrends` | Fetch global or by-category trends depending on sub-view |
| `useCompare` | Fetch MoM / YoY; budget mode delegates to `BudgetVsActualTable` |
| `useCategoryZoom` | Fetch zoom data with rollup toggle |
Each page mounts only the hook it needs; no hook carries state for reports the user is not currently viewing.
### Dynamic pivot removal
Removed outright rather than hidden behind a feature flag. A runtime flag would leave `getDynamicReportData` and its dynamic `FIELD_SQL` in the shipped bundle as a dead-but-live attack surface (OWASP A05:2021). Git history preserves the previous implementation if it ever needs to come back.
### Contextual keyword editing
Right-clicking a transaction row anywhere transaction-level (category zoom, highlights top transactions, main transactions page) opens an `AddKeywordDialog` that:
1. Validates the keyword is 264 characters after trim (anti-ReDoS, CWE-1333).
2. Previews matching transactions via a parameterised `LIKE $1` query, then filters in memory with the existing `buildKeywordRegex` helper (anti-SQL-injection, CWE-89).
3. Caps the visible preview at 50 rows; an explicit opt-in checkbox lets the user extend the apply to N50 non-displayed matches.
4. Runs INSERT (or UPDATE-reassign) + per-transaction UPDATEs inside a single SQL transaction (`BEGIN`/`COMMIT`/`ROLLBACK`), so a crash mid-apply can never leave a keyword orphaned from its transactions (CWE-662).
5. Renders transaction descriptions as React children — never `dangerouslySetInnerHTML` — with CSS-only truncation (CWE-79).
6. Recategorises only the rows the user explicitly checked; never retroactive on the entire history.
Reassigning an existing keyword across categories requires an explicit confirmation step and leaves the existing keyword's historical matches alone.
### Category zoom cycle guard
`getCategoryZoom` aggregates via a **bounded** recursive CTE (`WITH RECURSIVE ... WHERE ct.depth < 5`) so a corrupted `parent_id` loop (A B A) can never spin forever (CWE-835). A unit test with a canned cyclic fixture asserts termination.
## Consequences
### Positive
- Reports now tell a story ("what moved") before offering analytic depth.
- Each sub-route is independently code-splittable and testable.
- Period state is bookmarkable and shareable (copy URL → same view).
- Keyword hygiene happens inside the report, with a preview that's impossible in the old flow.
- The dialog's security guarantees are covered by 13 vitest cases (validation boundaries, parameterised LIKE, regex word-boundary filter, BEGIN/COMMIT wrap, ROLLBACK on failure, reassignment policy).
- The cycle guard is covered by its own test with the depth assertion.
### Negative / trade-offs
- Adds five new hooks and ~10 new components. Cognitive surface goes up but each piece is smaller and single-purpose.
- Aggregate tables in the compare and highlights sections intentionally skip the right-click menu (the row represents a category/month, not a transaction, so "add as keyword" is meaningless there). Users looking for consistency may be briefly confused.
- Right-clicking inside the main transactions page now offers two ways to add a keyword: the existing inline Tag button (no preview) and the new contextual dialog (with preview). Documented as complementary — the inline path is for quick manual classification, the dialog for preview-backed rule authoring.
## Alternatives considered
- **Keep the five-tab layout and only improve the pivot.** Rejected — it doesn't fix the "no narrative" issue and leaves the oversized pivot problem.
- **Hide the pivot behind a feature flag.** Rejected — the code stays in the bundle, runtime flag cannot be tree-shaken, and the i18n `reports.pivot.*` keys would have to linger indefinitely. Outright removal with git as the escape hatch was cheaper and cleaner.
- **React context for the shared period.** Rejected — the project does not use global React contexts for UI state. Query-string persistence is simpler, bookmarkable, and consistent with the rest of the codebase.
- **A single `ContextMenu` implementation shared across reports and charts.** Chose to generalise the existing `ChartContextMenu` into a `ContextMenu` shell; `ChartContextMenu` now composes the shared shell. Avoids duplicating click-outside + Escape handling.
## References
- Spec: `spec-refonte-rapports.md`
- Issues: #69 (foundation), #70 (hooks), #71 (highlights + hub), #72 (trends), #73 (compare), #74 (category zoom + AddKeywordDialog), #75 (right-click propagation), #76 (polish)
- OWASP A03:2021 (injection), A05:2021 (security misconfiguration)
- CWE-79 (XSS), CWE-89 (SQL injection), CWE-662 (improper synchronization), CWE-835 (infinite loop), CWE-1333 (ReDoS)

View file

@ -1,6 +1,6 @@
# Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-03-07 — Version 0.6.3
> Document mis à jour le 2026-04-13 — Version 0.7.3
## Stack technique
@ -26,7 +26,7 @@
```
simpl-resultat/
├── src/ # Frontend React/TypeScript
│ ├── components/ # 55 composants organisés par domaine
│ ├── components/ # 58 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants
│ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants
@ -34,13 +34,13 @@ simpl-resultat/
│ │ ├── import/ # 13 composants (wizard d'import)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
│ │ ├── settings/ # 3 composants (+ LogViewerCard)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
│ │ ├── shared/ # 6 composants réutilisables
│ │ └── transactions/ # 5 composants
│ ├── contexts/ # ProfileContext (état global profil)
│ ├── hooks/ # 12 hooks custom (useReducer)
│ ├── pages/ # 10 pages
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 14 services métier
│ ├── shared/ # Types et constantes partagés
│ ├── utils/ # 4 utilitaires (parsing, CSV, charts)
@ -49,10 +49,13 @@ simpl-resultat/
│ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust
│ ├── src/
│ │ ├── commands/ # 3 modules de commandes Tauri
│ │ ├── commands/ # 6 modules de commandes Tauri
│ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs
│ │ │ └── profile_commands.rs
│ │ │ ├── profile_commands.rs
│ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs
│ │ │ └── entitlements.rs
│ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql
@ -107,7 +110,7 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
## Services TypeScript (15)
## Services TypeScript (17)
| Service | Responsabilité |
|---------|---------------|
@ -118,16 +121,18 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `importSourceService.ts` | Configuration des sources d'import |
| `importedFileService.ts` | Suivi des fichiers importés |
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
| `categorizationService.ts` | Catégorisation automatique |
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
| `adjustmentService.ts` | Gestion des ajustements |
| `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord |
| `reportService.ts` | Génération de rapports et analytique |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
| `dataExportService.ts` | Export de données (chiffré) |
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
## Hooks (12)
## Hooks (14)
Chaque hook encapsule la logique d'état via `useReducer` :
@ -141,12 +146,19 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements |
| `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord |
| `useReports` | Données analytiques |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
| `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
| `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre |
| `useUpdater` | Mise à jour de l'application |
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
| `useLicense` | État de la licence et entitlements |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
## Commandes Tauri (18)
## Commandes Tauri (35)
### `fs_commands.rs` — Système de fichiers (6)
@ -171,10 +183,87 @@ Chaque hook encapsule la logique d'état via `useReducer` :
- `save_profiles` — Sauvegarde de la configuration
- `delete_profile_db` — Suppression du fichier de base de données
- `get_new_profile_init_sql` — Récupération du schéma consolidé
- `hash_pin` — Hachage Argon2 du PIN
- `verify_pin` — Vérification du PIN
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`)
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
### `license_commands.rs` — Licence et activation machine (10)
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
- `store_license` — Stockage de la clé dans le répertoire app data
- `store_activation_token` — Stockage du token d'activation
- `read_license` — Lecture de la licence stockée
- `get_edition` — Détection de l'édition active (free/base/premium)
- `get_machine_id` — Génération d'un identifiant machine unique
- `activate_machine` — Activation en ligne (appel API serveur de licences, issue #49)
- `deactivate_machine` — Désactivation d'une machine enregistrée
- `list_activated_machines` — Liste des machines activées pour la licence
- `get_activation_status` — État d'activation de la machine courante
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
- `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto
- `refresh_auth_token` — Rafraîchit l'access token via le refresh token
- `get_account_info` — Lecture du cache d'affichage (via `account_cache::load_unverified`, accepte les payloads legacy)
- `check_subscription_status` — Vérifie l'abonnement (max 1×/jour, fallback cache gracieux). Déclenche aussi la migration `tokens.json` → keychain via `token_store::load`
- `logout` — Efface tokens (`token_store`) + cache signé (`account_cache`) + clé HMAC du keychain
Note : `handle_auth_callback` n'est PAS exposée comme commande — elle est appelée depuis le handler deep-link `on_open_url` dans `lib.rs`. Voir section "OAuth2 et deep-link" plus bas.
### `token_store.rs` — Stockage des tokens OAuth (1)
- `get_token_store_mode` — Retourne `"keychain"`, `"file"` ou `null`. Utilisé par la bannière de sécurité `TokenStoreFallbackBanner` dans Settings pour alerter l'utilisateur quand les tokens sont dans le fallback fichier.
Module non-command : `save`, `load`, `delete`, `store_mode` — toute la logique de persistance passe par ce module, `auth_commands.rs` ne touche jamais directement `tokens.json`. Voir l'ADR 0006 pour la conception complète.
### `account_cache.rs` — Cache d'abonnement signé (aucune commande)
Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`. Expose :
- `save(app, &AccountInfo)` — écrit l'enveloppe signée `{data, sig}` dans `account.json`, avec clé HMAC-SHA256 stockée dans le keychain.
- `load_unverified(app)` — lecture pour affichage UI (accepte legacy et signé).
- `load_verified(app)` — lecture pour gating licence (refuse legacy, tampering, absence de clé). Utilisé par `license_commands::check_account_edition`.
- `delete(app)` — efface le fichier et la clé HMAC du keychain.
### `entitlements.rs` — Entitlements (1)
- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live
## Plugins Tauri
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
| Plugin | Rôle | Contrainte |
|--------|------|-----------|
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
| `tauri-plugin-process` | Relaunch après mise à jour | — |
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` |
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
## OAuth2 et deep-link (Compte Maximus)
Flow complet (v0.7.3+) :
1. Frontend appelle `start_oauth` → génère un code verifier PKCE (64 chars), le stocke dans `OAuthState` (Mutex en mémoire du processus), retourne l'URL Logto
2. Frontend ouvre l'URL via `tauri-plugin-opener` → le navigateur système affiche la page Logto
3. L'utilisateur s'authentifie (ou Logto auto-consent si session existante) → redirection 303 vers `simpl-resultat://auth/callback?code=...`
4. L'OS route le custom scheme vers une nouvelle instance de l'app → `tauri-plugin-single-instance` (feature `deep-link`) détecte l'instance existante, **ne démarre PAS un nouveau processus**, et forwarde l'URL à l'instance vivante
5. Le callback `app.deep_link().on_open_url(...)` enregistré via `DeepLinkExt` reçoit les URLs. Pour chaque URL :
- Si un param `code` est présent → appelle `handle_auth_callback` (token exchange vers `/oidc/token`, fetch `/oidc/me`, écriture des tokens via `token_store::save` (keychain OS, fallback fichier 0600) + cache signé via `account_cache::save` (HMAC-SHA256), émission de l'event `auth-callback-success`)
- Si un param `error` est présent → émission de l'event `auth-callback-error` avec `error: error_description`
6. Le hook `useAuth` (frontend) écoute `auth-callback-success` / `auth-callback-error` et met à jour l'état
Pourquoi cet enchaînement est critique :
- **Sans `tauri-plugin-single-instance`** : une nouvelle instance démarre à chaque callback, le `OAuthState` est vide (pas de verifier), le token exchange échoue
- **Sans `on_open_url`** : l'ancien listener `app.listen("deep-link://new-url", ...)` ne recevait pas les URLs forwardées par single-instance. L'API canonique v2 via `DeepLinkExt` est nécessaire
- **Sans gestion des erreurs** : un callback `?error=...` laissait l'UI bloquée en état "loading" infini
Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_commands.rs` (PKCE + token exchange), `src-tauri/src/commands/token_store.rs` (persistance keychain + fallback), `src-tauri/src/commands/account_cache.rs` (cache signé HMAC), `src/hooks/useAuth.ts` (frontend), `src/components/settings/TokenStoreFallbackBanner.tsx` (UI de l'état dégradé).
## Pages et routing
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
@ -196,7 +285,12 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
| `/budget` | `BudgetPage` | Planification budgétaire |
| `/reports` | `ReportsPage` | Analytique et rapports |
| `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 4 cartes de navigation |
| `/reports/highlights` | `ReportsHighlightsPage` | Faits saillants détaillés (soldes, top mouvements, top transactions) |
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
| `/settings` | `SettingsPage` | Paramètres |
| `/docs` | `DocsPage` | Documentation in-app |
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
@ -213,12 +307,24 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
## CI/CD
Workflow GitHub Actions (`release.yml`) déclenché par les tags `v*` :
Deux workflows Forgejo Actions (avec miroir GitHub) dans `.forgejo/workflows/` :
### `check.yml` — Vérifications sur branches et PR
Déclenché sur chaque push de branche (sauf `main`) et chaque PR vers `main`. Lance en parallèle :
- `cargo check` + `cargo test` (Rust)
- `npm run build` (tsc + vite)
- `npm test` (vitest)
Doit être vert avant tout merge. Évite de découvrir des régressions au moment du tag de release.
### `release.yml` — Build et publication
Déclenché par les tags `v*`. Deux jobs :
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
2. **build-linux** (ubuntu-22.04) → `.deb` + `.AppImage`
2. **build-linux** (ubuntu-22.04) → `.deb` + `.rpm`
Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater pour mises à jour automatiques
- Release GitHub automatique avec notes d'installation
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md

View file

@ -246,51 +246,56 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
## 9. Rapports
Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.
`/reports` est un **hub** qui répond à quatre questions : *qu'est-ce qui a bougé ce mois ?*, *où je vais sur 12 mois ?*, *comment je me situe vs période précédente ou vs budget ?*, *que se passe-t-il dans cette catégorie ?*
### Fonctionnalités
### Le hub
- Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en barres empilées), avec filtre par type (dépense/revenu/transfert)
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
- Détail des transactions par catégorie avec tri par colonne (date, description, montant)
- Toggle pour afficher ou masquer les montants dans le détail des transactions
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, quatre cartes mènent aux quatre sous-rapports dédiés.
### Comment faire
Le sélecteur de période en haut à droite est **partagé** entre toutes les pages via l'URL : `?from=YYYY-MM-DD&to=YYYY-MM-DD`. Copiez l'URL pour revenir plus tard au même état ou la partager.
1. Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel
2. Ajustez la période avec le sélecteur de période
3. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
4. Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher
5. Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel
6. Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions
7. Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants
### Rapport Faits saillants (`/reports/highlights`)
- Tuiles de soldes mois courant + YTD avec sparklines 12 mois
- Tableau triable des **top mouvements** (catégories avec la plus forte variation vs mois précédent), ou graphique en barres divergentes centré sur zéro (toggle graphique/tableau)
- Liste des **plus grosses transactions récentes** avec fenêtre configurable 30 / 60 / 90 jours
### Rapport Tendances (`/reports/trends`)
- **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau
- **Par catégorie** : évolution de chaque catégorie, en lignes ou tableau pivot
### Rapport Comparables (`/reports/compare`)
Trois modes accessibles via un tab bar :
- **Mois vs mois précédent** — tableau catégories × 2 colonnes + écart $ et %
- **Année vs année précédente** — même logique sur 12 mois vs 12 mois
- **Réel vs budget** — reprend la vue Budget vs Réel avec ses totaux mensuels et cumul annuel
### Rapport Zoom catégorie (`/reports/category`)
Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez :
- Un **donut chart** de la répartition par sous-catégorie avec le total au centre
- Un graphique d'évolution mensuelle de la catégorie
- Un tableau triable des transactions
### Édition contextuelle des mots-clés
**Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite)
2. Un sélecteur de catégorie cible
3. Un bouton **Appliquer et recatégoriser**
L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées.
### Astuces
- Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser
- Le sélecteur de période s'applique à tous les onglets de graphiques simultanément
- Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie
- Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques
### Rapport dynamique
Le rapport dynamique fonctionne comme un tableau croisé dynamique (pivot table). Vous composez votre propre rapport en assignant des dimensions et des mesures.
**Dimensions disponibles :** Année, Mois, Type (dépense/revenu/transfert), Catégorie Niveau 1 (parent), Catégorie Niveau 2 (enfant).
**Mesures :** Montant périodique (somme), Cumul annuel (YTD).
1. Cliquez sur un champ disponible dans le panneau de droite
2. Choisissez où le placer : Lignes, Colonnes, Filtres ou Valeurs
3. Le tableau et/ou le graphique se mettent à jour automatiquement
4. Utilisez les filtres pour restreindre les données (ex : Type = dépense uniquement)
5. Basculez entre les vues Tableau, Graphique ou Les deux
6. Cliquez sur le X pour retirer un champ d'une zone
- Le toggle **graphique / tableau** est mémorisé par sous-rapport (vos préférences restent même après redémarrage)
- Les mots-clés doivent faire entre 2 et 64 caractères (protection contre les regex explosives)
- Le zoom catégorie est **protégé contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
---
@ -304,6 +309,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Guide d'utilisation complet accessible directement depuis les paramètres
- Vérification automatique des mises à jour avec installation en un clic
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi)
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
- Import des données depuis un fichier exporté précédemment
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés
@ -313,9 +319,10 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
4. Utilisez la section Gestion des données pour exporter ou importer vos données
5. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
6. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut
5. Utilisez la section Gestion des données pour exporter ou importer vos données
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
### Astuces
@ -324,4 +331,5 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Exportez régulièrement pour garder une sauvegarde de vos données
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
- En cas de problème, copiez les journaux et joignez-les à votre signalement
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement

11
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "simpl_result_scaffold",
"version": "0.6.6",
"version": "0.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl_result_scaffold",
"version": "0.6.6",
"version": "0.7.3",
"license": "GPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -3297,10 +3297,11 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View file

@ -1,7 +1,7 @@
{
"name": "simpl_result_scaffold",
"private": true,
"version": "0.6.7",
"version": "0.8.2",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {

841
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "simpl-result"
version = "0.6.7"
version = "0.8.2"
description = "Personal finance management app"
license = "GPL-3.0-only"
authors = ["you"]
@ -25,6 +25,8 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
@ -34,9 +36,23 @@ encoding_rs = "0.8"
walkdir = "2"
aes-gcm = "0.10"
argon2 = "0.5"
subtle = "2"
rand = "0.8"
jsonwebtoken = "9"
machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["macros"] }
hostname = "0.4"
urlencoding = "2"
base64 = "0.22"
# OAuth token storage in OS keychain (Credential Manager on Windows,
# Secret Service on Linux). We use sync-secret-service to get sync
# methods that are safe to call from async Tauri commands without
# tokio runtime entanglement. Requires libdbus-1-dev at build time
# on Linux (libdbus-1-3 is present on every desktop Linux at runtime).
keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] }
zeroize = "1"
hmac = "0.12"
[dev-dependencies]
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`

View file

@ -0,0 +1,292 @@
// Integrity-protected cache for cached account info.
//
// The user's subscription tier is used by `license_commands::current_edition`
// to unlock Premium features. Until this module, the subscription_status
// claim was read directly from a plaintext `account.json` on disk, which
// meant any local process (malware, nosy user, curl) could write
// `{"subscription_status": "active"}` and bypass the paywall without
// ever touching the Logto session. This module closes that trap.
//
// Approach: an HMAC-SHA256 signature is computed over the serialized
// AccountInfo bytes using a per-install key stored in the OS keychain
// (via the `keyring` crate, same backend as token_store). The signed
// payload is wrapped as `{"data": {...}, "sig": "<base64>"}`. On read,
// verification requires the same key; tampering with either `data` or
// `sig` invalidates the cache.
//
// Trust chain:
// - Key lives in the keychain, scoped to service "com.simpl.resultat",
// user "account-hmac-key". Compromising it requires compromising the
// keychain, which is the existing trust boundary for OAuth tokens.
// - A legacy unsigned `account.json` (from v0.7.x) is still readable
// for display purposes (email, name, picture), but the gating path
// uses `load_verified` which returns None for legacy payloads —
// Premium features stay locked until the next token refresh rewrites
// the file with a signature.
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::fs;
use super::auth_commands::AccountInfo;
use super::token_store::{auth_dir, write_restricted};
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
const KEYCHAIN_USER_HMAC: &str = "account-hmac-key";
const ACCOUNT_FILE: &str = "account.json";
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Serialize, Deserialize)]
struct SignedAccount {
data: AccountInfo,
sig: String,
}
/// Read the HMAC key from the keychain, creating a fresh random key on
/// first use. The key is never persisted to disk — if the keychain is
/// unreachable, the whole cache signing/verification path falls back
/// to "not signed" which means gating stays locked until the keychain
/// is available again. This is intentional (fail-closed).
fn get_or_create_key() -> Result<[u8; 32], String> {
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC)
.map_err(|e| format!("Keychain entry init failed: {}", e))?;
match entry.get_password() {
Ok(b64) => decode_key(&b64),
Err(keyring::Error::NoEntry) => {
use rand::RngCore;
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
let encoded = encode_key(&key);
entry
.set_password(&encoded)
.map_err(|e| format!("Keychain HMAC key write failed: {}", e))?;
Ok(key)
}
Err(e) => Err(format!("Keychain HMAC key read failed: {}", e)),
}
}
fn encode_key(key: &[u8]) -> String {
use base64::{engine::general_purpose::STANDARD, Engine};
STANDARD.encode(key)
}
fn decode_key(raw: &str) -> Result<[u8; 32], String> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD
.decode(raw.trim())
.map_err(|e| format!("Invalid HMAC key encoding: {}", e))?;
if bytes.len() != 32 {
return Err(format!(
"HMAC key must be 32 bytes, got {}",
bytes.len()
));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(arr)
}
fn sign(key: &[u8; 32], payload: &[u8]) -> String {
use base64::{engine::general_purpose::STANDARD, Engine};
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take any key length");
mac.update(payload);
STANDARD.encode(mac.finalize().into_bytes())
}
fn verify(key: &[u8; 32], payload: &[u8], sig_b64: &str) -> bool {
use base64::{engine::general_purpose::STANDARD, Engine};
let Ok(sig_bytes) = STANDARD.decode(sig_b64) else {
return false;
};
let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
return false;
};
mac.update(payload);
mac.verify_slice(&sig_bytes).is_ok()
}
/// Write the account cache as `{"data": {...}, "sig": "..."}`. The key
/// is fetched from (or created in) the keychain. Writes fall back to an
/// unsigned legacy-shape payload only when the keychain is unreachable
/// — this keeps the UI functional on keychain-less systems but means
/// the gating path will refuse to grant Premium until the keychain
/// comes back.
pub fn save(app: &tauri::AppHandle, account: &AccountInfo) -> Result<(), String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
match get_or_create_key() {
Ok(key) => {
// Serialise the AccountInfo alone (compact, deterministic
// for a given struct layout), sign the bytes, then re-wrap
// into the signed envelope. We write the signed envelope
// as pretty-printed JSON for readability in the audit log.
let data_bytes =
serde_json::to_vec(account).map_err(|e| format!("Serialize error: {}", e))?;
let sig = sign(&key, &data_bytes);
let envelope = SignedAccount {
data: account.clone(),
sig,
};
let json = serde_json::to_string_pretty(&envelope)
.map_err(|e| format!("Serialize envelope error: {}", e))?;
write_restricted(&path, &json)
}
Err(err) => {
eprintln!(
"account_cache: keychain HMAC key unavailable, writing unsigned legacy payload ({})",
err
);
// Fallback: unsigned payload. UI still works, but
// `load_verified` will reject this file for gating.
let json = serde_json::to_string_pretty(account)
.map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&path, &json)
}
}
}
/// Load the cached account for **display purposes**. Accepts both the
/// new signed envelope and legacy plaintext AccountInfo files. Does
/// NOT verify the signature — suitable for showing the user's name /
/// email / picture in the UI, but never for gating decisions.
pub fn load_unverified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
// Prefer the signed envelope shape; fall back to legacy flat
// AccountInfo so upgraded users see their account info immediately
// rather than a blank card until the next token refresh.
if let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) {
return Ok(Some(envelope.data));
}
if let Ok(flat) = serde_json::from_str::<AccountInfo>(&raw) {
return Ok(Some(flat));
}
Err("Invalid account cache payload".to_string())
}
/// Load the cached account and verify the HMAC signature. Used by the
/// license gating path (`current_edition`). Returns Ok(None) when:
/// - no cache exists,
/// - the cache is in legacy unsigned shape (pre-v0.8 or post-fallback),
/// - the keychain HMAC key is unreachable,
/// - the signature does not verify.
///
/// Any of these states must cause Premium features to stay locked —
/// never accept an unverifiable payload for a gating decision.
pub fn load_verified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
// Only signed envelopes are acceptable here. Legacy flat payloads
// are treated as "no verified account".
let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) else {
return Ok(None);
};
let Ok(key) = get_or_create_key() else {
return Ok(None);
};
let data_bytes = match serde_json::to_vec(&envelope.data) {
Ok(v) => v,
Err(_) => return Ok(None),
};
if !verify(&key, &data_bytes, &envelope.sig) {
return Ok(None);
}
Ok(Some(envelope.data))
}
/// Delete the cached account file AND the keychain HMAC key. Called on
/// logout so the next login generates a fresh key bound to the new
/// session.
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if path.exists() {
let _ = fs::remove_file(&path);
}
if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC) {
let _ = entry.delete_credential();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_account() -> AccountInfo {
AccountInfo {
email: "user@example.com".into(),
name: Some("Test User".into()),
picture: None,
subscription_status: Some("active".into()),
}
}
#[test]
fn sign_then_verify_same_key() {
let key = [7u8; 32];
let payload = b"hello world";
let sig = sign(&key, payload);
assert!(verify(&key, payload, &sig));
}
#[test]
fn verify_rejects_tampered_payload() {
let key = [7u8; 32];
let sig = sign(&key, b"original");
assert!(!verify(&key, b"tampered", &sig));
}
#[test]
fn verify_rejects_wrong_key() {
let key_a = [7u8; 32];
let key_b = [8u8; 32];
let sig = sign(&key_a, b"payload");
assert!(!verify(&key_b, b"payload", &sig));
}
#[test]
fn envelope_roundtrip_serde() {
let account = sample_account();
let key = [3u8; 32];
let data = serde_json::to_vec(&account).unwrap();
let sig = sign(&key, &data);
let env = SignedAccount {
data: account.clone(),
sig,
};
let json = serde_json::to_string(&env).unwrap();
let decoded: SignedAccount = serde_json::from_str(&json).unwrap();
let decoded_data = serde_json::to_vec(&decoded.data).unwrap();
assert!(verify(&key, &decoded_data, &decoded.sig));
}
#[test]
fn encode_decode_key_roundtrip() {
let key = [42u8; 32];
let encoded = encode_key(&key);
let decoded = decode_key(&encoded).unwrap();
assert_eq!(decoded, key);
}
#[test]
fn decode_key_rejects_wrong_length() {
use base64::{engine::general_purpose::STANDARD, Engine};
let short = STANDARD.encode([1u8; 16]);
assert!(decode_key(&short).is_err());
}
}

View file

@ -0,0 +1,323 @@
// OAuth2 PKCE flow for Compte Maximus (Logto) integration.
//
// Architecture:
// - The desktop app is registered as a "Native App" in Logto (public
// client, no secret).
// - OAuth2 Authorization Code + PKCE flow via the system browser.
// - Deep-link callback: simpl-resultat://auth/callback?code=...
// - Tokens are persisted through `token_store` which prefers the OS
// keychain (Credential Manager / Secret Service) and falls back to a
// restricted file only when no prior keychain success has been
// recorded. See `token_store.rs` for details.
//
// The PKCE verifier is held in memory via Tauri managed state, so it
// cannot be intercepted by another process. It is cleared after the
// callback exchange.
use serde::{Deserialize, Serialize};
use std::fs;
use std::sync::Mutex;
use tauri::Manager;
use super::account_cache;
use super::token_store::{
self, auth_dir, chrono_now, write_restricted, StoredTokens,
};
// Logto endpoint — overridable via env var for development.
fn logto_endpoint() -> String {
std::env::var("LOGTO_ENDPOINT")
.unwrap_or_else(|_| "https://auth.lacompagniemaximus.com".to_string())
}
// Logto app ID for the desktop native app.
fn logto_app_id() -> String {
std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "sr-desktop-native".to_string())
}
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
const LAST_CHECK_FILE: &str = "last_check";
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
/// PKCE state held in memory during the OAuth2 flow.
pub struct OAuthState {
pub code_verifier: Mutex<Option<String>>,
}
/// Account info exposed to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountInfo {
pub email: String,
pub name: Option<String>,
pub picture: Option<String>,
pub subscription_status: Option<String>,
}
fn generate_pkce() -> (String, String) {
use rand::Rng;
let mut rng = rand::thread_rng();
let verifier: String = (0..64)
.map(|_| {
let idx = rng.gen_range(0..62);
let c = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx];
c as char
})
.collect();
use sha2::{Digest, Sha256};
let hash = Sha256::digest(verifier.as_bytes());
let challenge = base64_url_encode(&hash);
(verifier, challenge)
}
fn base64_url_encode(data: &[u8]) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
URL_SAFE_NO_PAD.encode(data)
}
/// Start the OAuth2 PKCE flow. Generates a code verifier/challenge, stores the verifier
/// in memory, and returns the authorization URL to open in the system browser.
#[tauri::command]
pub fn start_oauth(app: tauri::AppHandle) -> Result<String, String> {
let (verifier, challenge) = generate_pkce();
// Store verifier in managed state
let state = app.state::<OAuthState>();
*state.code_verifier.lock().map_err(|e| format!("Mutex poisoned: {}", e))? = Some(verifier);
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let url = format!(
"{}/oidc/auth?client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=openid%20profile%20email%20offline_access",
endpoint,
urlencoding::encode(&client_id),
urlencoding::encode(REDIRECT_URI),
urlencoding::encode(&challenge),
);
Ok(url)
}
/// Exchange the authorization code for tokens. Called from the deep-link callback handler.
#[tauri::command]
pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result<AccountInfo, String> {
let verifier = {
let state = app.state::<OAuthState>();
let verifier = state.code_verifier.lock().map_err(|e| format!("Mutex poisoned: {}", e))?.take();
verifier.ok_or("No pending OAuth flow (verifier missing)")?
};
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/oidc/token", endpoint))
.form(&[
("grant_type", "authorization_code"),
("client_id", &client_id),
("redirect_uri", REDIRECT_URI),
("code", &code),
("code_verifier", &verifier),
])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Token exchange failed: {}", e))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Token exchange error: {}", body));
}
let token_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid token response: {}", e))?;
let access_token = token_resp["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();
let refresh_token = token_resp["refresh_token"].as_str().map(|s| s.to_string());
let id_token = token_resp["id_token"].as_str().map(|s| s.to_string());
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let expires_at = chrono_now() + expires_in;
// Persist tokens through token_store (prefers keychain over file).
let tokens = StoredTokens {
access_token: access_token.clone(),
refresh_token,
id_token,
expires_at,
};
token_store::save(&app, &tokens)?;
// Fetch user info
let account = fetch_userinfo(&endpoint, &access_token).await?;
// Store account info with an HMAC signature so the license gating
// path can trust the cached subscription_status without re-calling
// Logto on every entitlement check.
account_cache::save(&app, &account)?;
Ok(account)
}
/// Refresh the access token using the stored refresh token.
#[tauri::command]
pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, String> {
let tokens = token_store::load(&app)?.ok_or_else(|| "Not authenticated".to_string())?;
let refresh_token = tokens
.refresh_token
.ok_or("No refresh token available")?;
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/oidc/token", endpoint))
.form(&[
("grant_type", "refresh_token"),
("client_id", &client_id),
("refresh_token", &refresh_token),
])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Token refresh failed: {}", e))?;
if !resp.status().is_success() {
// Clear stored tokens on refresh failure.
let _ = token_store::delete(&app);
let _ = account_cache::delete(&app);
return Err("Session expired, please sign in again".to_string());
}
let token_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid token response: {}", e))?;
let new_access = token_resp["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();
let new_refresh = token_resp["refresh_token"].as_str().map(|s| s.to_string());
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let new_tokens = StoredTokens {
access_token: new_access.clone(),
refresh_token: new_refresh.or(Some(refresh_token)),
id_token: token_resp["id_token"].as_str().map(|s| s.to_string()),
expires_at: chrono_now() + expires_in,
};
token_store::save(&app, &new_tokens)?;
let account = fetch_userinfo(&endpoint, &new_access).await?;
account_cache::save(&app, &account)?;
Ok(account)
}
/// Read cached account info without network call. Used for UI display
/// only — accepts both signed (v0.8+) and legacy (v0.7.x) payloads so
/// upgraded users still see their name/email immediately. The license
/// gating path uses `account_cache::load_verified` instead.
#[tauri::command]
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
account_cache::load_unverified(&app)
}
/// Log out: clear all stored tokens and account info, including the
/// HMAC key so the next session starts with a fresh cryptographic
/// anchor.
#[tauri::command]
pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
token_store::delete(&app)?;
account_cache::delete(&app)?;
Ok(())
}
/// Check subscription status if the last check was more than 24h ago.
/// Returns the refreshed account info, or the cached info if no check was needed.
/// Graceful: returns Ok(None) if not authenticated, silently skips on network errors.
#[tauri::command]
pub async fn check_subscription_status(
app: tauri::AppHandle,
) -> Result<Option<AccountInfo>, String> {
// Not authenticated — nothing to check. This also triggers migration
// from a legacy tokens.json file into the keychain when present,
// because token_store::load() performs the migration eagerly.
if token_store::load(&app)?.is_none() {
return Ok(None);
}
let dir = auth_dir(&app)?;
let last_check_path = dir.join(LAST_CHECK_FILE);
let now = chrono_now();
// Check if we need to verify (more than 24h since last check)
if last_check_path.exists() {
if let Ok(raw) = fs::read_to_string(&last_check_path) {
if let Ok(ts) = raw.trim().parse::<i64>() {
if now - ts < CHECK_INTERVAL_SECS {
// Recent check — return cached account info
return get_account_info(app);
}
}
}
}
// Try to refresh the token to get fresh subscription status
match refresh_auth_token(app.clone()).await {
Ok(account) => {
// Update last check timestamp
let _ = write_restricted(&last_check_path, &now.to_string());
Ok(Some(account))
}
Err(_) => {
// Network error or expired session — graceful degradation.
// Still update the timestamp to avoid hammering on every launch.
let _ = write_restricted(&last_check_path, &now.to_string());
get_account_info(app)
}
}
}
async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result<AccountInfo, String> {
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/oidc/me", endpoint))
.bearer_auth(access_token)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Userinfo fetch failed: {}", e))?;
if !resp.status().is_success() {
return Err("Cannot fetch user info".to_string());
}
let info: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid userinfo response: {}", e))?;
Ok(AccountInfo {
email: info["email"]
.as_str()
.unwrap_or_default()
.to_string(),
name: info["name"].as_str().map(|s| s.to_string()),
picture: info["picture"].as_str().map(|s| s.to_string()),
subscription_status: info["custom_data"]["subscription_status"]
.as_str()
.map(|s| s.to_string()),
})
}

View file

@ -12,7 +12,9 @@ pub const EDITION_PREMIUM: &str = "premium";
/// Maps feature name → list of editions allowed to use it.
/// A feature absent from this list is denied for all editions.
const FEATURE_TIERS: &[(&str, &[&str])] = &[
("auto-update", &[EDITION_BASE, EDITION_PREMIUM]),
// auto-update is temporarily open to FREE until the license server (issue #49)
// is live. Re-gate to [BASE, PREMIUM] once paid activation works end-to-end.
("auto-update", &[EDITION_FREE, EDITION_BASE, EDITION_PREMIUM]),
("web-sync", &[EDITION_PREMIUM]),
("cloud-backup", &[EDITION_PREMIUM]),
("advanced-reports", &[EDITION_PREMIUM]),
@ -38,8 +40,9 @@ mod tests {
use super::*;
#[test]
fn free_blocks_auto_update() {
assert!(!is_feature_allowed("auto-update", EDITION_FREE));
fn free_allows_auto_update_temporarily() {
// Temporary: auto-update is open to FREE until the license server is live.
assert!(is_feature_allowed("auto-update", EDITION_FREE));
}
#[test]

View file

@ -0,0 +1,159 @@
// Feedback Hub client — forwards user-submitted feedback to the central
// feedback-api service. Routed through Rust (not direct fetch) so that:
// - CORS is bypassed (Tauri origin is not whitelisted server-side by design)
// - The exact payload leaving the machine is auditable in a single place
// - The pattern matches the other outbound calls (OAuth, license, updater)
//
// The feedback-api contract is documented in
// `la-compagnie-maximus/docs/feedback-hub-ops.md`. The server silently drops
// any context key outside its whitelist, so this module only sends the
// fields declared in `Context` below.
use serde::{Deserialize, Serialize};
use std::time::Duration;
fn feedback_endpoint() -> String {
std::env::var("FEEDBACK_HUB_URL")
.unwrap_or_else(|_| "https://feedback.lacompagniemaximus.com".to_string())
}
/// Context payload sent with a feedback submission. Keys MUST match the
/// server whitelist in `feedback-api/index.js` — unknown keys are dropped
/// silently. Each field is capped at 500 chars server-side.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FeedbackContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport: Option<String>,
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Serialize)]
struct FeedbackPayload<'a> {
app_id: &'a str,
content: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<FeedbackContext>,
}
#[derive(Debug, Serialize)]
pub struct FeedbackSuccess {
pub id: String,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
struct FeedbackResponse {
id: String,
created_at: String,
}
/// Return a composed User-Agent string for the context payload, e.g.
/// `"Simpl'Résultat/0.8.1 (linux)"`. Uses std::env::consts::OS so we don't
/// pull in an extra Tauri plugin just for this.
#[tauri::command]
pub fn get_feedback_user_agent(app: tauri::AppHandle) -> String {
let version = app.package_info().version.to_string();
let os = std::env::consts::OS;
format!("Simpl'Résultat/{} ({})", version, os)
}
/// Submit a feedback to the Feedback Hub. Error strings are stable codes
/// ("invalid", "rate_limit", "server_error", "network_error") that the
/// frontend maps to i18n messages.
#[tauri::command]
pub async fn send_feedback(
content: String,
user_id: Option<String>,
context: Option<FeedbackContext>,
) -> Result<FeedbackSuccess, String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("invalid".to_string());
}
let payload = FeedbackPayload {
app_id: "simpl-resultat",
content: trimmed,
user_id,
context,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|_| "network_error".to_string())?;
let url = format!("{}/api/feedback", feedback_endpoint());
let res = client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|_| "network_error".to_string())?;
match res.status().as_u16() {
201 => {
let body: FeedbackResponse = res
.json()
.await
.map_err(|_| "server_error".to_string())?;
Ok(FeedbackSuccess {
id: body.id,
created_at: body.created_at,
})
}
400 => Err("invalid".to_string()),
429 => Err("rate_limit".to_string()),
_ => Err("server_error".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_skips_none_fields() {
let ctx = FeedbackContext {
page: Some("/settings".to_string()),
locale: Some("fr".to_string()),
theme: None,
viewport: None,
user_agent: None,
timestamp: None,
};
let json = serde_json::to_value(&ctx).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 2);
assert!(obj.contains_key("page"));
assert!(obj.contains_key("locale"));
}
#[test]
fn context_serializes_user_agent_camelcase() {
let ctx = FeedbackContext {
user_agent: Some("Simpl'Résultat/0.8.1 (linux)".to_string()),
..Default::default()
};
let json = serde_json::to_string(&ctx).unwrap();
assert!(json.contains("\"userAgent\""));
assert!(!json.contains("\"user_agent\""));
}
#[tokio::test]
async fn empty_content_is_rejected_locally() {
let res = send_feedback(" \n\t".to_string(), None, None).await;
assert_eq!(res.unwrap_err(), "invalid");
}
}

View file

@ -22,12 +22,10 @@ use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
// Ed25519 public key for license verification.
//
// IMPORTANT: this PEM is a development placeholder taken from RFC 8410 §10.3 test vectors.
// The matching private key is publicly known, so any license signed with it offers no real
// protection. Replace this constant with the production public key before shipping a paid
// release. The corresponding private key MUST live only on the license server (Issue #49).
// Production key generated 2026-04-10. The corresponding private key lives ONLY
// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM.
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\
-----END PUBLIC KEY-----\n";
const LICENSE_FILE: &str = "license.key";
@ -224,7 +222,16 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
/// failure resolves to "free" so feature gates fail closed.
///
/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
// Check Compte Maximus subscription first — Premium overrides Base
if let Some(edition) = check_account_edition(app) {
if edition == EDITION_PREMIUM {
return edition;
}
}
let Ok(path) = license_path(app) else {
return EDITION_FREE.to_string();
};
@ -260,6 +267,22 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
info.edition
}
/// Read the HMAC-verified account cache to check for an active Premium
/// subscription. Legacy unsigned caches (from v0.7.x) and tampered
/// payloads return None — Premium features stay locked until the user
/// re-authenticates or the next token refresh re-signs the cache.
///
/// This is intentional: before HMAC signing, any local process could
/// write `{"subscription_status": "active"}` to account.json and
/// bypass the paywall. Fail-closed is the correct posture here.
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
let account = super::account_cache::load_verified(app).ok().flatten()?;
match account.subscription_status.as_deref() {
Some("active") => Some(EDITION_PREMIUM.to_string()),
_ => None,
}
}
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
#[tauri::command]
@ -271,6 +294,202 @@ fn machine_id_internal() -> Result<String, String> {
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
}
// License server API base URL. Overridable via SIMPL_API_URL env var for development.
fn api_base_url() -> String {
std::env::var("SIMPL_API_URL")
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}
/// Machine info returned by the license server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineInfo {
pub machine_id: String,
pub machine_name: Option<String>,
pub activated_at: String,
pub last_seen_at: String,
}
/// Activation status for display in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivationStatus {
pub is_activated: bool,
pub machine_id: String,
}
/// Activate this machine with the license server. Reads the stored license key, sends
/// the machine_id to the API, and stores the returned activation token.
#[tauri::command]
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Err("No license key stored".to_string());
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let machine_id = machine_id_internal()?;
let machine_name = hostname::get()
.ok()
.and_then(|h| h.into_string().ok());
let url = format!("{}/licenses/activate", api_base_url());
let client = reqwest::Client::new();
let mut body = serde_json::json!({
"license_key": license_key.trim(),
"machine_id": machine_id,
});
if let Some(name) = machine_name {
body["machine_name"] = serde_json::Value::String(name);
}
let resp = client
.post(&url)
.json(&body)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
let status = resp.status();
let resp_body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid server response: {}", e))?;
if !status.is_success() {
let error = resp_body["error"]
.as_str()
.unwrap_or("Activation failed");
return Err(error.to_string());
}
let token = resp_body["activation_token"]
.as_str()
.ok_or("Server did not return an activation token")?;
// store_activation_token validates the token against local machine_id before writing
store_activation_token(app, token.to_string())?;
Ok(())
}
/// Deactivate a machine on the license server, freeing a slot.
#[tauri::command]
pub async fn deactivate_machine(
app: tauri::AppHandle,
machine_id: String,
) -> Result<(), String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Err("No license key stored".to_string());
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let url = format!("{}/licenses/deactivate", api_base_url());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"license_key": license_key.trim(),
"machine_id": machine_id,
}))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
let status = resp.status();
if !status.is_success() {
let resp_body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::json!({"error": "Deactivation failed"}));
let error = resp_body["error"].as_str().unwrap_or("Deactivation failed");
return Err(error.to_string());
}
// If deactivating this machine, remove the local activation token
let local_id = machine_id_internal()?;
if machine_id == local_id {
let act_path = activation_path(&app)?;
if act_path.exists() {
let _ = fs::remove_file(&act_path);
}
}
Ok(())
}
/// List all machines currently activated for the stored license.
#[tauri::command]
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Ok(vec![]);
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let url = format!("{}/licenses/verify", api_base_url());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"license_key": license_key.trim(),
}))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
if !resp.status().is_success() {
return Err("Cannot verify license".to_string());
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid server response: {}", e))?;
// The verify endpoint returns machines in the response when valid
let machines = body["machines"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(machines)
}
/// Check the local activation status without contacting the server.
#[tauri::command]
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
let machine_id = machine_id_internal()?;
let act_path = activation_path(&app)?;
let is_activated = if act_path.exists() {
if let Ok(token) = fs::read_to_string(&act_path) {
if let Ok(decoding_key) = embedded_decoding_key() {
validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok()
} else {
false
}
} else {
false
}
} else {
false
};
Ok(ActivationStatus {
is_activated,
machine_id,
})
}
// === Tests ====================================================================================
#[cfg(test)]

View file

@ -1,11 +1,18 @@
pub mod account_cache;
pub mod auth_commands;
pub mod entitlements;
pub mod export_import_commands;
pub mod feedback_commands;
pub mod fs_commands;
pub mod license_commands;
pub mod profile_commands;
pub mod token_store;
pub use auth_commands::*;
pub use entitlements::*;
pub use export_import_commands::*;
pub use feedback_commands::*;
pub use fs_commands::*;
pub use license_commands::*;
pub use profile_commands::*;
pub use token_store::*;

View file

@ -1,7 +1,9 @@
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha384};
use std::fs;
use subtle::ConstantTimeEq;
use tauri::Manager;
use crate::database;
@ -118,44 +120,103 @@ pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
])
}
#[tauri::command]
pub fn hash_pin(pin: String) -> Result<String, String> {
let mut salt = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut salt);
let salt_hex = hex_encode(&salt);
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
const ARGON2_M_COST: u32 = 65536; // 64 MiB
const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 1;
const ARGON2_OUTPUT_LEN: usize = 32;
const ARGON2_SALT_LEN: usize = 16;
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes());
let result = hasher.finalize();
let hash_hex = hex_encode(&result);
// Store as "salt:hash"
Ok(format!("{}:{}", salt_hex, hash_hex))
fn argon2_hash(pin: &str, salt: &[u8]) -> Result<Vec<u8>, String> {
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN))
.map_err(|e| format!("Argon2 params error: {}", e))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut hash = vec![0u8; ARGON2_OUTPUT_LEN];
argon2
.hash_password_into(pin.as_bytes(), salt, &mut hash)
.map_err(|e| format!("Argon2 hash error: {}", e))?;
Ok(hash)
}
#[tauri::command]
pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
pub fn hash_pin(pin: String) -> Result<String, String> {
let mut salt = [0u8; ARGON2_SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let salt_hex = hex_encode(&salt);
let hash = argon2_hash(&pin, &salt)?;
let hash_hex = hex_encode(&hash);
// Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash"
Ok(format!("argon2id:{}:{}", salt_hex, hash_hex))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyPinResult {
pub valid: bool,
/// New Argon2id hash when a legacy SHA-256 PIN was successfully verified and re-hashed
pub rehashed: Option<String>,
}
#[tauri::command]
pub fn verify_pin(pin: String, stored_hash: String) -> Result<VerifyPinResult, String> {
// Argon2id format: "argon2id:salt_hex:hash_hex"
if let Some(rest) = stored_hash.strip_prefix("argon2id:") {
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() != 2 {
return Err("Invalid Argon2id hash format".to_string());
}
let salt = hex_decode(parts[0])?;
let expected_hash = hex_decode(parts[1])?;
let computed = argon2_hash(&pin, &salt)?;
let valid = computed.ct_eq(&expected_hash).into();
return Ok(VerifyPinResult { valid, rehashed: None });
}
// Legacy SHA-256 format: "salt_hex:hash_hex"
let parts: Vec<&str> = stored_hash.split(':').collect();
if parts.len() != 2 {
return Err("Invalid stored hash format".to_string());
}
let salt_hex = parts[0];
let expected_hash = parts[1];
let expected_hash = hex_decode(parts[1])?;
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes());
let result = hasher.finalize();
let computed_hash = hex_encode(&result);
Ok(computed_hash == expected_hash)
let valid: bool = result.as_slice().ct_eq(&expected_hash).into();
if valid {
// Re-hash with Argon2id so this legacy PIN is upgraded.
// If rehash fails, still allow login — don't block the user.
let rehashed = hash_pin(pin).ok();
Ok(VerifyPinResult { valid: true, rehashed })
} else {
Ok(VerifyPinResult { valid: false, rehashed: None })
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
return Err("Invalid hex string length".to_string());
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(|e| format!("Invalid hex character: {}", e))
})
.collect()
}
/// Repair migration checksums for a profile database.
/// Updates stored checksums to match current migration SQL, avoiding re-application
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
@ -217,3 +278,98 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<b
Ok(repaired)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_pin_produces_argon2id_format() {
let hash = hash_pin("1234".to_string()).unwrap();
assert!(hash.starts_with("argon2id:"), "Hash should start with 'argon2id:' prefix");
let parts: Vec<&str> = hash.split(':').collect();
assert_eq!(parts.len(), 3, "Hash should have 3 parts: prefix:salt:hash");
assert_eq!(parts[1].len(), ARGON2_SALT_LEN * 2, "Salt should be {} hex chars", ARGON2_SALT_LEN * 2);
assert_eq!(parts[2].len(), ARGON2_OUTPUT_LEN * 2, "Hash should be {} hex chars", ARGON2_OUTPUT_LEN * 2);
}
#[test]
fn test_hash_pin_different_salts() {
let h1 = hash_pin("1234".to_string()).unwrap();
let h2 = hash_pin("1234".to_string()).unwrap();
assert_ne!(h1, h2, "Two hashes of the same PIN should use different salts");
}
#[test]
fn test_verify_argon2id_pin_correct() {
let hash = hash_pin("5678".to_string()).unwrap();
let result = verify_pin("5678".to_string(), hash).unwrap();
assert!(result.valid, "Correct PIN should verify");
assert!(result.rehashed.is_none(), "Argon2id PIN should not be rehashed");
}
#[test]
fn test_verify_argon2id_pin_wrong() {
let hash = hash_pin("5678".to_string()).unwrap();
let result = verify_pin("0000".to_string(), hash).unwrap();
assert!(!result.valid, "Wrong PIN should not verify");
assert!(result.rehashed.is_none());
}
#[test]
fn test_verify_legacy_sha256_correct_and_rehash() {
// Create a legacy SHA-256 hash: "salt_hex:sha256(salt_hex + pin)"
let salt_hex = "abcdef0123456789";
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(b"4321");
let hash_bytes = hasher.finalize();
let hash_hex = hex_encode(&hash_bytes);
let stored = format!("{}:{}", salt_hex, hash_hex);
let result = verify_pin("4321".to_string(), stored).unwrap();
assert!(result.valid, "Correct legacy PIN should verify");
assert!(result.rehashed.is_some(), "Legacy PIN should be rehashed to Argon2id");
// Verify the rehashed value is a valid Argon2id hash
let new_hash = result.rehashed.unwrap();
assert!(new_hash.starts_with("argon2id:"));
// Verify the rehashed value works for future verification
let result2 = verify_pin("4321".to_string(), new_hash).unwrap();
assert!(result2.valid, "Rehashed PIN should verify");
assert!(result2.rehashed.is_none(), "Already Argon2id, no rehash needed");
}
#[test]
fn test_verify_legacy_sha256_wrong() {
let salt_hex = "abcdef0123456789";
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(b"4321");
let hash_bytes = hasher.finalize();
let hash_hex = hex_encode(&hash_bytes);
let stored = format!("{}:{}", salt_hex, hash_hex);
let result = verify_pin("9999".to_string(), stored).unwrap();
assert!(!result.valid, "Wrong legacy PIN should not verify");
assert!(result.rehashed.is_none(), "Failed verification should not rehash");
}
#[test]
fn test_verify_invalid_format() {
let result = verify_pin("1234".to_string(), "invalid".to_string());
assert!(result.is_err(), "Single-part hash should fail");
let result = verify_pin("1234".to_string(), "argon2id:bad".to_string());
assert!(result.is_err(), "Argon2id with wrong part count should fail");
}
#[test]
fn test_hex_roundtrip() {
let original = vec![0u8, 127, 255, 1, 16];
let encoded = hex_encode(&original);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(original, decoded);
}
}

View file

@ -0,0 +1,393 @@
// OAuth token storage abstraction.
//
// This module centralises how OAuth2 tokens are persisted. It tries the OS
// keychain first (Credential Manager on Windows, Secret Service on Linux
// via libdbus) and falls back to a restricted file on disk if the keychain
// is unavailable.
//
// Security properties:
// - The keychain service name matches the Tauri bundle identifier
// (com.simpl.resultat) so OS tools and future macOS builds can scope
// credentials correctly.
// - A `store_mode` flag is persisted next to the fallback file. Once the
// keychain has been used successfully, the store refuses to silently
// downgrade to the file fallback: a subsequent keychain failure is
// surfaced as an error so the caller can force re-authentication
// instead of leaving the user with undetected plaintext tokens.
// - Migration from an existing `tokens.json` file zeros the file contents
// and fsyncs before unlinking, reducing the window where the refresh
// token is recoverable from unallocated disk blocks.
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::Manager;
use zeroize::Zeroize;
// Keychain identifiers. The service name matches tauri.conf.json's
// `identifier` so credentials are scoped to the real app identity.
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
const KEYCHAIN_USER_TOKENS: &str = "oauth-tokens";
pub(crate) const AUTH_DIR: &str = "auth";
const TOKENS_FILE: &str = "tokens.json";
const STORE_MODE_FILE: &str = "store_mode";
/// Where token material currently lives. Exposed via a Tauri command so
/// the frontend can display a security banner when the app has fallen
/// back to the file store.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StoreMode {
Keychain,
File,
}
impl StoreMode {
fn as_str(&self) -> &'static str {
match self {
StoreMode::Keychain => "keychain",
StoreMode::File => "file",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"keychain" => Some(StoreMode::Keychain),
"file" => Some(StoreMode::File),
_ => None,
}
}
}
/// Serialised OAuth token bundle. Owned by `token_store` because this is
/// the only module that should reach for the persisted bytes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub expires_at: i64,
}
/// Resolve `<app_data_dir>/auth/`, creating it if needed. Shared with
/// auth_commands.rs which still writes non-secret files (account info,
/// last-check timestamp) in the same directory.
pub(crate) fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?
.join(AUTH_DIR);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
}
Ok(dir)
}
/// Write a file with 0600 permissions on Unix. Windows has no cheap
/// equivalent here; callers that rely on this function for secrets should
/// treat the Windows path as a last-resort fallback and surface the
/// degraded state to the user (see StoreMode).
pub(crate) fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.write_all(contents.as_bytes())
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.sync_all()
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
}
#[cfg(not(unix))]
{
fs::write(path, contents)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
}
Ok(())
}
pub(crate) fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn read_store_mode(dir: &Path) -> Option<StoreMode> {
let path = dir.join(STORE_MODE_FILE);
let raw = fs::read_to_string(&path).ok()?;
StoreMode::parse(&raw)
}
fn write_store_mode(dir: &Path, mode: StoreMode) -> Result<(), String> {
write_restricted(&dir.join(STORE_MODE_FILE), mode.as_str())
}
/// Overwrite the file contents with zeros, fsync, then unlink. Best-effort
/// mitigation against recovery of the refresh token from unallocated
/// blocks on copy-on-write filesystems where a plain unlink leaves the
/// ciphertext recoverable. Not a substitute for proper disk encryption.
fn zero_and_delete(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let len = fs::metadata(path)
.map(|m| m.len() as usize)
.unwrap_or(0)
.max(512);
let mut zeros = vec![0u8; len];
{
let mut f = fs::OpenOptions::new()
.write(true)
.truncate(false)
.open(path)
.map_err(|e| format!("Cannot open {} for wipe: {}", path.display(), e))?;
f.write_all(&zeros)
.map_err(|e| format!("Cannot zero {}: {}", path.display(), e))?;
f.sync_all()
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
}
zeros.zeroize();
fs::remove_file(path).map_err(|e| format!("Cannot remove {}: {}", path.display(), e))
}
fn tokens_to_json(tokens: &StoredTokens) -> Result<String, String> {
serde_json::to_string(tokens).map_err(|e| format!("Serialize error: {}", e))
}
fn tokens_from_json(raw: &str) -> Result<StoredTokens, String> {
serde_json::from_str(raw).map_err(|e| format!("Invalid tokens payload: {}", e))
}
fn keychain_entry() -> Result<keyring::Entry, keyring::Error> {
keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_TOKENS)
}
fn keychain_save(json: &str) -> Result<(), keyring::Error> {
keychain_entry()?.set_password(json)
}
fn keychain_load() -> Result<Option<String>, keyring::Error> {
match keychain_entry()?.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e),
}
}
fn keychain_delete() -> Result<(), keyring::Error> {
match keychain_entry()?.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e),
}
}
/// Persist the current OAuth token bundle.
///
/// Tries the OS keychain first. If the keychain write fails AND the
/// persisted `store_mode` flag shows the keychain has worked before, the
/// caller receives an error instead of a silent downgrade — this
/// prevents a hostile local process from forcing the app into the
/// weaker file-fallback path. On a fresh install (no prior flag), the
/// fallback is allowed but recorded so subsequent calls know the app is
/// running in degraded mode.
pub fn save(app: &tauri::AppHandle, tokens: &StoredTokens) -> Result<(), String> {
let json = tokens_to_json(tokens)?;
let dir = auth_dir(app)?;
let previous_mode = read_store_mode(&dir);
match keychain_save(&json) {
Ok(()) => {
// Keychain succeeded. Clean up any residual file from a prior
// fallback or migration source so nothing stays readable.
let residual = dir.join(TOKENS_FILE);
if residual.exists() {
let _ = zero_and_delete(&residual);
}
write_store_mode(&dir, StoreMode::Keychain)?;
Ok(())
}
Err(err) => {
if previous_mode == Some(StoreMode::Keychain) {
// Refuse to downgrade after a prior success — surface the
// failure so the caller can force re-auth instead of
// silently leaking tokens to disk.
return Err(format!(
"Keychain unavailable after prior success — refusing to downgrade. \
Original error: {}",
err
));
}
eprintln!(
"token_store: keychain unavailable, falling back to file store ({})",
err
);
write_restricted(&dir.join(TOKENS_FILE), &json)?;
write_store_mode(&dir, StoreMode::File)?;
Ok(())
}
}
}
/// Load the current OAuth token bundle.
///
/// Tries the keychain first. If the keychain is empty but a legacy
/// `tokens.json` file exists, this is a first-run migration: the tokens
/// are copied into the keychain, the file is zeroed and unlinked, and
/// `store_mode` is updated. If the keychain itself is unreachable, the
/// function falls back to reading the file — unless the `store_mode`
/// flag indicates the keychain has worked before, in which case it
/// returns an error to force re-auth.
pub fn load(app: &tauri::AppHandle) -> Result<Option<StoredTokens>, String> {
let dir = auth_dir(app)?;
let previous_mode = read_store_mode(&dir);
let residual = dir.join(TOKENS_FILE);
match keychain_load() {
Ok(Some(raw)) => {
let tokens = tokens_from_json(&raw)?;
// Defensive: if a leftover file is still around (e.g. a prior
// crash between keychain write and file delete), clean it up.
if residual.exists() {
let _ = zero_and_delete(&residual);
}
if previous_mode != Some(StoreMode::Keychain) {
write_store_mode(&dir, StoreMode::Keychain)?;
}
Ok(Some(tokens))
}
Ok(None) => {
// Keychain reachable but empty. Migrate from a legacy file if
// one exists, otherwise report no stored session.
if residual.exists() {
let raw = fs::read_to_string(&residual)
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
let tokens = tokens_from_json(&raw)?;
// Push into keychain and wipe the file. If the keychain
// push fails here, we keep the file rather than losing
// the user's session.
match keychain_save(&raw) {
Ok(()) => {
let _ = zero_and_delete(&residual);
write_store_mode(&dir, StoreMode::Keychain)?;
}
Err(e) => {
eprintln!("token_store: migration to keychain failed ({})", e);
write_store_mode(&dir, StoreMode::File)?;
}
}
Ok(Some(tokens))
} else {
Ok(None)
}
}
Err(err) => {
if previous_mode == Some(StoreMode::Keychain) {
return Err(format!(
"Keychain unavailable after prior success: {}",
err
));
}
// No prior keychain success: honour the file fallback if any.
eprintln!(
"token_store: keychain unreachable, using file fallback ({})",
err
);
if residual.exists() {
let raw = fs::read_to_string(&residual)
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
write_store_mode(&dir, StoreMode::File)?;
Ok(Some(tokens_from_json(&raw)?))
} else {
Ok(None)
}
}
}
}
/// Delete the stored tokens from both the keychain and the file
/// fallback. Both deletions are best-effort and ignore "no entry"
/// errors to stay idempotent.
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
let dir = auth_dir(app)?;
if let Err(err) = keychain_delete() {
eprintln!("token_store: keychain delete failed ({})", err);
}
let residual = dir.join(TOKENS_FILE);
if residual.exists() {
let _ = zero_and_delete(&residual);
}
// Leave the store_mode flag alone: it still describes what the app
// should trust the next time `save` is called.
Ok(())
}
/// Current store mode, derived from the persisted flag. Returns `None`
/// if no tokens have ever been written (no flag file).
pub fn store_mode(app: &tauri::AppHandle) -> Result<Option<StoreMode>, String> {
let dir = auth_dir(app)?;
Ok(read_store_mode(&dir))
}
/// Tauri command: expose the current store mode to the frontend.
/// Returns `"keychain"`, `"file"`, or `null` if the app has no stored
/// session yet. Used by the settings UI to show a security banner when
/// the fallback is active.
#[tauri::command]
pub fn get_token_store_mode(app: tauri::AppHandle) -> Result<Option<String>, String> {
Ok(store_mode(&app)?.map(|m| m.as_str().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_mode_roundtrip() {
assert_eq!(StoreMode::parse("keychain"), Some(StoreMode::Keychain));
assert_eq!(StoreMode::parse("file"), Some(StoreMode::File));
assert_eq!(StoreMode::parse("other"), None);
assert_eq!(StoreMode::Keychain.as_str(), "keychain");
assert_eq!(StoreMode::File.as_str(), "file");
}
#[test]
fn stored_tokens_serde_roundtrip() {
let tokens = StoredTokens {
access_token: "at".into(),
refresh_token: Some("rt".into()),
id_token: Some("it".into()),
expires_at: 42,
};
let json = tokens_to_json(&tokens).unwrap();
let decoded = tokens_from_json(&json).unwrap();
assert_eq!(decoded.access_token, "at");
assert_eq!(decoded.refresh_token.as_deref(), Some("rt"));
assert_eq!(decoded.id_token.as_deref(), Some("it"));
assert_eq!(decoded.expires_at, 42);
}
#[test]
fn zero_and_delete_removes_file() {
use std::io::Write as _;
let tmp = std::env::temp_dir().join(format!(
"simpl-resultat-token-store-test-{}",
std::process::id()
));
let mut f = fs::File::create(&tmp).unwrap();
f.write_all(b"sensitive").unwrap();
drop(f);
assert!(tmp.exists());
zero_and_delete(&tmp).unwrap();
assert!(!tmp.exists());
}
}

View file

@ -1,6 +1,9 @@
mod commands;
mod database;
use std::sync::Mutex;
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -82,12 +85,63 @@ pub fn run() {
];
tauri::Builder::default()
// Single-instance plugin MUST be registered first. With the `deep-link`
// feature, it forwards `simpl-resultat://` URLs to the running instance
// so the OAuth2 callback reaches the process that holds the PKCE verifier.
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}))
.manage(commands::auth_commands::OAuthState {
code_verifier: Mutex::new(None),
})
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Register the custom scheme at runtime on Linux (the .desktop file
// handles it in prod, but register_all is a no-op there and required
// for AppImage/dev builds).
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
let _ = app.deep_link().register_all();
}
// Canonical Tauri v2 pattern: on_open_url fires for both initial-launch
// URLs and subsequent URLs forwarded by tauri-plugin-single-instance
// (with the `deep-link` feature).
let handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
let url_str = url.as_str();
let h = handle.clone();
if let Some(code) = extract_auth_code(url_str) {
tauri::async_runtime::spawn(async move {
match commands::handle_auth_callback(h.clone(), code).await {
Ok(account) => {
let _ = h.emit("auth-callback-success", &account);
}
Err(err) => {
let _ = h.emit("auth-callback-error", &err);
}
}
});
} else {
// No `code` param — likely an OAuth error response. Surface
// it to the frontend instead of leaving the UI stuck in
// "loading" forever.
let err_msg = extract_auth_error(url_str)
.unwrap_or_else(|| "OAuth callback did not include a code".to_string());
let _ = h.emit("auth-callback-error", &err_msg);
}
}
});
Ok(())
})
.plugin(
@ -121,7 +175,52 @@ pub fn run() {
commands::get_edition,
commands::get_machine_id,
commands::check_entitlement,
commands::activate_machine,
commands::deactivate_machine,
commands::list_activated_machines,
commands::get_activation_status,
commands::start_oauth,
commands::refresh_auth_token,
commands::get_account_info,
commands::check_subscription_status,
commands::logout,
commands::get_token_store_mode,
commands::send_feedback,
commands::get_feedback_user_agent,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// Extract the `code` query parameter from a deep-link callback URL.
/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123")
fn extract_auth_code(url: &str) -> Option<String> {
extract_query_param(url, "code")
}
/// Extract an OAuth error description from a callback URL. Returns a
/// formatted string combining `error` and `error_description` when present.
fn extract_auth_error(url: &str) -> Option<String> {
let error = extract_query_param(url, "error")?;
match extract_query_param(url, "error_description") {
Some(desc) => Some(format!("{}: {}", error, desc)),
None => Some(error),
}
}
fn extract_query_param(url: &str, key: &str) -> Option<String> {
let url = url.trim();
if !url.starts_with("simpl-resultat://auth/callback") {
return None;
}
let query = url.split('?').nth(1)?;
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next()? == key {
return kv.next().map(|v| {
urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string())
});
}
}
None
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat",
"version": "0.6.7",
"version": "0.8.2",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",
@ -18,12 +18,12 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com https://feedback.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
}
},
"bundle": {
"active": true,
"targets": ["nsis", "deb", "rpm", "appimage"],
"targets": ["nsis", "deb", "rpm"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@ -34,6 +34,11 @@
"createUpdaterArtifacts": true
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["simpl-resultat"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
"endpoints": [

View file

@ -10,6 +10,11 @@ import CategoriesPage from "./pages/CategoriesPage";
import AdjustmentsPage from "./pages/AdjustmentsPage";
import BudgetPage from "./pages/BudgetPage";
import ReportsPage from "./pages/ReportsPage";
import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage";
import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage";
@ -101,6 +106,11 @@ export default function App() {
<Route path="/adjustments" element={<AdjustmentsPage />} />
<Route path="/budget" element={<BudgetPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
<Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} />

View file

@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
import {
KEYWORD_MAX_LENGTH,
KEYWORD_MIN_LENGTH,
KEYWORD_PREVIEW_LIMIT,
applyKeywordWithReassignment,
previewKeywordMatches,
validateKeyword,
} from "../../services/categorizationService";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
interface CategoryOption {
id: number;
name: string;
}
export interface AddKeywordDialogProps {
initialKeyword: string;
initialCategoryId?: number | null;
onClose: () => void;
onApplied?: () => void;
}
type PreviewState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; visible: RecentTransaction[]; totalMatches: number }
| { kind: "error"; message: string };
export default function AddKeywordDialog({
initialKeyword,
initialCategoryId = null,
onClose,
onApplied,
}: AddKeywordDialogProps) {
const { t } = useTranslation();
const [keyword, setKeyword] = useState(initialKeyword);
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId);
const [categories, setCategories] = useState<CategoryOption[]>([]);
const [checked, setChecked] = useState<Set<number>>(new Set());
const [applyToHidden, setApplyToHidden] = useState(false);
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [replacePrompt, setReplacePrompt] = useState<string | null>(null);
const [allowReplaceExisting, setAllowReplaceExisting] = useState(false);
const validation = useMemo(() => validateKeyword(keyword), [keyword]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name }))))
.catch(() => setCategories([]));
}, []);
useEffect(() => {
if (!validation.ok) {
setPreview({ kind: "idle" });
return;
}
let cancelled = false;
setPreview({ kind: "loading" });
previewKeywordMatches(validation.value, KEYWORD_PREVIEW_LIMIT)
.then(({ visible, totalMatches }) => {
if (cancelled) return;
setPreview({ kind: "ready", visible, totalMatches });
setChecked(new Set(visible.map((tx) => tx.id)));
})
.catch((e: unknown) => {
if (cancelled) return;
setPreview({ kind: "error", message: e instanceof Error ? e.message : String(e) });
});
return () => {
cancelled = true;
};
}, [validation]);
const toggleRow = (id: number) => {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const canApply =
validation.ok &&
categoryId !== null &&
preview.kind === "ready" &&
!isApplying &&
checked.size > 0;
const handleApply = async () => {
if (!validation.ok || categoryId === null) return;
setIsApplying(true);
setApplyError(null);
try {
await applyKeywordWithReassignment({
keyword: validation.value,
categoryId,
transactionIds: Array.from(checked),
allowReplaceExisting,
});
if (onApplied) onApplied();
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === "keyword_already_exists") {
setReplacePrompt(t("reports.keyword.alreadyExists", { category: "" }));
} else {
setApplyError(msg);
}
} finally {
setIsApplying(false);
}
};
const preventBackdropClose = (e: React.MouseEvent) => e.stopPropagation();
return (
<div
onClick={onClose}
className="fixed inset-0 z-[200] bg-black/40 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div
onClick={preventBackdropClose}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl max-w-xl w-full max-h-[90vh] flex flex-col"
>
<header className="px-5 py-3 border-b border-[var(--border)]">
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
</header>
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.keyword.dialogTitle")}
</span>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
maxLength={KEYWORD_MAX_LENGTH * 2 /* allow user to paste longer then see error */}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
/>
{!validation.ok && (
<span className="text-xs text-[var(--negative)]">
{validation.reason === "tooShort"
? t("reports.keyword.tooShort", { min: KEYWORD_MIN_LENGTH })
: t("reports.keyword.tooLong", { max: KEYWORD_MAX_LENGTH })}
</span>
)}
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.category")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<section>
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
{t("reports.keyword.willMatch")}
</h3>
{preview.kind === "idle" && (
<p className="text-sm text-[var(--muted-foreground)] italic"></p>
)}
{preview.kind === "loading" && (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("common.loading")}</p>
)}
{preview.kind === "error" && (
<p className="text-sm text-[var(--negative)]">{preview.message}</p>
)}
{preview.kind === "ready" && (
<>
<p className="text-sm mb-2">
{t("reports.keyword.nMatches", { count: preview.totalMatches })}
</p>
<ul className="divide-y divide-[var(--border)] max-h-[220px] overflow-y-auto border border-[var(--border)] rounded-lg">
{preview.visible.map((tx) => (
<li key={tx.id} className="flex items-center gap-2 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={checked.has(tx.id)}
onChange={() => toggleRow(tx.id)}
className="accent-[var(--primary)]"
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
<span className="tabular-nums font-medium flex-shrink-0">
{new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
}).format(tx.amount)}
</span>
</li>
))}
</ul>
{preview.totalMatches > preview.visible.length && (
<label className="flex items-center gap-2 text-xs mt-2 text-[var(--muted-foreground)]">
<input
type="checkbox"
checked={applyToHidden}
onChange={(e) => setApplyToHidden(e.target.checked)}
className="accent-[var(--primary)]"
/>
{t("reports.keyword.applyToHidden", {
count: preview.totalMatches - preview.visible.length,
})}
</label>
)}
</>
)}
</section>
{replacePrompt && (
<div className="bg-[var(--muted)]/50 border border-[var(--border)] rounded-lg p-3 text-sm flex flex-col gap-2">
<p>{replacePrompt}</p>
<button
type="button"
onClick={() => {
setAllowReplaceExisting(true);
setReplacePrompt(null);
void handleApply();
}}
className="self-start px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm"
>
{t("common.confirm")}
</button>
</div>
)}
{applyError && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-lg p-3 text-sm">
{applyError}
</div>
)}
</div>
<footer className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-2 rounded-lg text-sm bg-[var(--card)] border border-[var(--border)] hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleApply}
disabled={!canApply}
className="px-3 py-2 rounded-lg text-sm bg-[var(--primary)] text-white font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isApplying ? t("common.loading") : t("reports.keyword.applyAndRecategorize")}
</button>
</footer>
</div>
</div>
);
}

View file

@ -6,7 +6,7 @@ import { verifyPin } from "../../services/profileService";
interface Props {
profileName: string;
storedHash: string;
onSuccess: () => void;
onSuccess: (rehashed?: string | null) => void;
onCancel: () => void;
}
@ -41,9 +41,9 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
if (value && filledCount === index + 1) {
setChecking(true);
try {
const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash);
if (valid) {
onSuccess();
const result = await verifyPin(pin.replace(/\s/g, ""), storedHash);
if (result.valid) {
onSuccess(result.rehashed);
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
setError(true);
setDigits(["", "", "", "", "", ""]);
@ -67,10 +67,10 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
const pin = digits.join("");
if (pin.length >= 4) {
setChecking(true);
verifyPin(pin, storedHash).then((valid) => {
verifyPin(pin, storedHash).then((result) => {
setChecking(false);
if (valid) {
onSuccess();
if (result.valid) {
onSuccess(result.rehashed);
} else {
setError(true);
setDigits(["", "", "", "", "", ""]);

View file

@ -8,7 +8,7 @@ import type { Profile } from "../../services/profileService";
export default function ProfileSwitcher() {
const { t } = useTranslation();
const { profiles, activeProfile, switchProfile } = useProfile();
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile();
const [open, setOpen] = useState(false);
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
const [showManage, setShowManage] = useState(false);
@ -36,8 +36,15 @@ export default function ProfileSwitcher() {
}
};
const handlePinSuccess = () => {
const handlePinSuccess = async (rehashed?: string | null) => {
if (pinProfile) {
if (rehashed) {
try {
await updateProfile(pinProfile.id, { pin_hash: rehashed });
} catch {
// Best-effort rehash: don't block profile switch if persistence fails
}
}
switchProfile(pinProfile.id);
setPinProfile(null);
}

View file

@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import type { CategoryZoomChild } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
export interface CategoryDonutChartProps {
byChild: CategoryZoomChild[];
centerLabel: string;
centerValue: string;
}
export default function CategoryDonutChart({
byChild,
centerLabel,
centerValue,
}: CategoryDonutChartProps) {
const { t } = useTranslation();
if (byChild.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 relative">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<ChartPatternDefs
prefix="cat-donut"
categories={byChild.map((c, index) => ({ color: c.categoryColor, index }))}
/>
<Pie
data={byChild}
dataKey="total"
nameKey="categoryName"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={95}
paddingAngle={2}
>
{byChild.map((entry, index) => (
<Cell
key={entry.categoryId}
fill={getPatternFill("cat-donut", index, entry.categoryColor)}
/>
))}
</Pie>
<Tooltip
formatter={(value) =>
typeof value === "number"
? new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(value)
: String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-xs text-[var(--muted-foreground)]">{centerLabel}</span>
<span className="text-lg font-bold">{centerValue}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { CategoryZoomEvolutionPoint } from "../../shared/types";
export interface CategoryEvolutionChartProps {
data: CategoryZoomEvolutionPoint[];
color?: string;
}
function formatMonth(month: string): string {
const [year, m] = month.split("-");
const date = new Date(Number(year), Number(m) - 1);
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
export default function CategoryEvolutionChart({
data,
color = "var(--primary)",
}: CategoryEvolutionChartProps) {
const { t, i18n } = useTranslation();
if (data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<h3 className="text-sm font-semibold mb-2">{t("reports.category.evolution")}</h3>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(v)
}
/>
<Tooltip
formatter={(value) =>
typeof value === "number"
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(value)
: String(value)
}
labelFormatter={(label) => (typeof label === "string" ? formatMonth(label) : String(label ?? ""))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Area type="monotone" dataKey="total" stroke={color} fill={color} fillOpacity={0.2} />
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,135 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Tag } from "lucide-react";
import type { RecentTransaction } from "../../shared/types";
import ContextMenu from "../shared/ContextMenu";
export interface CategoryTransactionsTableProps {
transactions: RecentTransaction[];
onAddKeyword?: (transaction: RecentTransaction) => void;
}
type SortKey = "date" | "description" | "amount";
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function CategoryTransactionsTable({
transactions,
onAddKeyword,
}: CategoryTransactionsTableProps) {
const { t, i18n } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>("amount");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
const sorted = useMemo(() => {
const copy = [...transactions];
copy.sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "date":
cmp = a.date.localeCompare(b.date);
break;
case "description":
cmp = a.description.localeCompare(b.description);
break;
case "amount":
cmp = Math.abs(a.amount) - Math.abs(b.amount);
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
return copy;
}, [transactions, sortKey, sortDir]);
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir("desc");
}
}
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
if (!onAddKeyword) return;
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, tx });
};
const header = (key: SortKey, label: string, align: "left" | "right") => (
<th
onClick={() => toggleSort(key)}
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
>
{label}
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
</th>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-[var(--card)]">
<tr className="border-b border-[var(--border)]">
{header("date", t("transactions.date"), "left")}
{header("description", t("transactions.description"), "left")}
{header("amount", t("transactions.amount"), "right")}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={3} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
sorted.map((tx) => (
<tr
key={tx.id}
onContextMenu={(e) => handleContextMenu(e, tx)}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2 tabular-nums text-[var(--muted-foreground)]">{tx.date}</td>
<td className="px-3 py-2 truncate max-w-[280px]">{tx.description}</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)",
}}
>
{formatAmount(tx.amount, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{menu && onAddKeyword && (
<ContextMenu
x={menu.x}
y={menu.y}
header={menu.tx.description}
onClose={() => setMenu(null)}
items={[
{
icon: <Tag size={14} />,
label: t("reports.keyword.addFromTransaction"),
onClick: () => {
onAddKeyword(menu.tx);
},
},
]}
/>
)}
</div>
);
}

View file

@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
interface CategoryOption {
id: number;
name: string;
color: string | null;
parent_id: number | null;
}
export interface CategoryZoomHeaderProps {
categoryId: number | null;
includeSubcategories: boolean;
onCategoryChange: (id: number | null) => void;
onIncludeSubcategoriesChange: (flag: boolean) => void;
}
export default function CategoryZoomHeader({
categoryId,
includeSubcategories,
onCategoryChange,
onIncludeSubcategoriesChange,
}: CategoryZoomHeaderProps) {
const { t } = useTranslation();
const [categories, setCategories] = useState<CategoryOption[]>([]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) =>
setCategories(
rows.map((r) => ({ id: r.id, name: r.name, color: r.color, parent_id: r.parent_id })),
),
)
.catch(() => setCategories([]));
}, []);
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<label className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.category.selectCategory")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => onCategoryChange(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSubcategories}
onChange={(e) => onIncludeSubcategoriesChange(e.target.checked)}
className="accent-[var(--primary)]"
/>
<span>
{includeSubcategories
? t("reports.category.includeSubcategories")
: t("reports.category.directOnly")}
</span>
</label>
</div>
);
}

View file

@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import BudgetVsActualTable from "./BudgetVsActualTable";
import { getBudgetVsActualData } from "../../services/budgetService";
import type { BudgetVsActualRow } from "../../shared/types";
export interface CompareBudgetViewProps {
year: number;
month: number;
}
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) {
const { t } = useTranslation();
const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
getBudgetVsActualData(year, month)
.then((data) => {
if (!cancelled) setRows(data);
})
.catch((e: unknown) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, [year, month]);
if (error) {
return (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4">{error}</div>
);
}
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.bva.noData")}
</div>
);
}
return <BudgetVsActualTable data={rows} />;
}

View file

@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import type { CompareMode } from "../../hooks/useCompare";
export interface CompareModeTabsProps {
value: CompareMode;
onChange: (mode: CompareMode) => void;
}
export default function CompareModeTabs({ value, onChange }: CompareModeTabsProps) {
const { t } = useTranslation();
const modes: { id: CompareMode; labelKey: string }[] = [
{ id: "actual", labelKey: "reports.compare.modeActual" },
{ id: "budget", labelKey: "reports.compare.modeBudget" },
];
return (
<div className="inline-flex gap-1" role="tablist">
{modes.map(({ id, labelKey }) => (
<button
key={id}
type="button"
role="tab"
onClick={() => onChange(id)}
aria-selected={value === id}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === id
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(labelKey)}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,111 @@
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import type { CategoryDelta } from "../../shared/types";
export interface ComparePeriodChartProps {
rows: CategoryDelta[];
previousLabel: string;
currentLabel: string;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
export default function ComparePeriodChart({
rows,
previousLabel,
currentLabel,
}: ComparePeriodChartProps) {
const { t, i18n } = useTranslation();
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
// Sort by current-period amount (largest spending first) so the user's eye
// lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart.
const chartData = [...rows]
.sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({
name: r.categoryName,
previousAmount: r.previousAmount,
currentAmount: r.currentAmount,
color: r.categoryColor,
}));
const previousFill = "var(--muted-foreground)";
const currentFill = "var(--primary)";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={Math.max(280, chartData.length * 44 + 60)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
barCategoryGap="20%"
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
<XAxis
type="number"
tickFormatter={(v) => formatCurrency(v, i18n.language)}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis
type="category"
dataKey="name"
width={140}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<Bar
dataKey="previousAmount"
name={previousLabel}
fill={previousFill}
radius={[0, 4, 4, 0]}
/>
<Bar
dataKey="currentAmount"
name={currentLabel}
fill={currentFill}
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,118 @@
import { useTranslation } from "react-i18next";
import type { CategoryDelta } from "../../shared/types";
export interface ComparePeriodTableProps {
rows: CategoryDelta[];
previousLabel: string;
currentLabel: string;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
export default function ComparePeriodTable({
rows,
previousLabel,
currentLabel,
}: ComparePeriodTableProps) {
const { t, i18n } = useTranslation();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.category")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{previousLabel}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{currentLabel}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.variationAbs")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.variationPct")}
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2">
<span className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color:
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatSignedCurrency(row.deltaAbs, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums"
style={{
color:
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatPct(row.deltaPct, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export interface CompareReferenceMonthPickerProps {
year: number;
month: number;
onChange: (year: number, month: number) => void;
/** Number of recent months to show in the dropdown. Default: 24. */
monthCount?: number;
/** "today" override for tests. */
today?: Date;
}
interface Option {
value: string; // "YYYY-MM"
year: number;
month: number;
label: string;
}
function formatMonth(year: number, month: number, language: string): string {
const date = new Date(year, month - 1, 1);
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(date);
}
export default function CompareReferenceMonthPicker({
year,
month,
onChange,
monthCount = 24,
today = new Date(),
}: CompareReferenceMonthPickerProps) {
const { t, i18n } = useTranslation();
const options = useMemo<Option[]>(() => {
const list: Option[] = [];
let y = today.getFullYear();
let m = today.getMonth() + 1;
for (let i = 0; i < monthCount; i++) {
list.push({
value: `${y}-${String(m).padStart(2, "0")}`,
year: y,
month: m,
label: formatMonth(y, m, i18n.language),
});
m -= 1;
if (m === 0) {
m = 12;
y -= 1;
}
}
return list;
}, [today, monthCount, i18n.language]);
const currentValue = `${year}-${String(month).padStart(2, "0")}`;
const isKnown = options.some((o) => o.value === currentValue);
const displayOptions = isKnown
? options
: [
{
value: currentValue,
year,
month,
label: formatMonth(year, month, i18n.language),
},
...options,
];
return (
<label className="inline-flex items-center gap-2">
<span className="text-sm text-[var(--muted-foreground)]">
{t("reports.compare.referenceMonth")}
</span>
<select
value={currentValue}
onChange={(e) => {
const opt = displayOptions.find((o) => o.value === e.target.value);
if (opt) onChange(opt.year, opt.month);
}}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] px-3 py-2 text-sm hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
>
{displayOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}

View file

@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import type { CompareSubMode } from "../../hooks/useCompare";
export interface CompareSubModeToggleProps {
value: CompareSubMode;
onChange: (subMode: CompareSubMode) => void;
}
export default function CompareSubModeToggle({ value, onChange }: CompareSubModeToggleProps) {
const { t } = useTranslation();
const items: { id: CompareSubMode; labelKey: string }[] = [
{ id: "mom", labelKey: "reports.compare.subModeMoM" },
{ id: "yoy", labelKey: "reports.compare.subModeYoY" },
];
return (
<div
className="inline-flex rounded-lg border border-[var(--border)] bg-[var(--card)] p-0.5"
role="group"
aria-label={t("reports.compare.subModeAria")}
>
{items.map(({ id, labelKey }) => (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-pressed={value === id}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
value === id
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(labelKey)}
</button>
))}
</div>
);
}

View file

@ -1,106 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Table, BarChart3, Columns, Maximize2, Minimize2 } from "lucide-react";
import type { PivotConfig, PivotResult } from "../../shared/types";
import DynamicReportPanel from "./DynamicReportPanel";
import DynamicReportTable from "./DynamicReportTable";
import DynamicReportChart from "./DynamicReportChart";
type ViewMode = "table" | "chart" | "both";
interface DynamicReportProps {
config: PivotConfig;
result: PivotResult;
onConfigChange: (config: PivotConfig) => void;
}
export default function DynamicReport({ config, result, onConfigChange }: DynamicReportProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [fullscreen, setFullscreen] = useState(false);
const toggleFullscreen = useCallback(() => setFullscreen((prev) => !prev), []);
// Escape key exits fullscreen
useEffect(() => {
if (!fullscreen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setFullscreen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [fullscreen]);
const hasConfig = (config.rows.length > 0 || config.columns.length > 0) && config.values.length > 0;
const viewButtons: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
{ mode: "table", icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
{ mode: "both", icon: <Columns size={14} />, label: t("reports.pivot.viewBoth") },
];
return (
<div
className={
fullscreen
? "fixed inset-0 z-50 bg-[var(--background)] overflow-auto p-6"
: ""
}
>
<div className="flex gap-4 items-start">
{/* Content area */}
<div className="flex-1 min-w-0 space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-1">
{hasConfig && viewButtons.map(({ mode, icon, label }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
mode === viewMode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
<div className="flex-1" />
<button
onClick={toggleFullscreen}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
title={fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
>
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
{fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
</button>
</div>
{/* Empty state */}
{!hasConfig && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-12 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noConfig")}
</div>
)}
{/* Table */}
{hasConfig && (viewMode === "table" || viewMode === "both") && (
<DynamicReportTable config={config} result={result} />
)}
{/* Chart */}
{hasConfig && (viewMode === "chart" || viewMode === "both") && (
<DynamicReportChart config={config} result={result} />
)}
</div>
{/* Side panel */}
<DynamicReportPanel
config={config}
onChange={onConfigChange}
/>
</div>
</div>
);
}

View file

@ -1,143 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Legend,
CartesianGrid,
} from "recharts";
import type { PivotConfig, PivotResult } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
// Generate distinct colors for series
const SERIES_COLORS = [
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
"#d946ef", "#0ea5e9", "#eab308", "#22c55e", "#e11d48",
];
interface DynamicReportChartProps {
config: PivotConfig;
result: PivotResult;
}
export default function DynamicReportChart({ config, result }: DynamicReportChartProps) {
const { t } = useTranslation();
const { chartData, seriesKeys, seriesColors } = useMemo(() => {
if (result.rows.length === 0) {
return { chartData: [], seriesKeys: [], seriesColors: {} };
}
const colDims = config.columns;
const rowDim = config.rows[0];
const measure = config.values[0] || "periodic";
// X-axis = composite column key (or first row dimension if no columns)
const hasColDims = colDims.length > 0;
if (!hasColDims && !rowDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
// Build composite column key per row
const getColKey = (r: typeof result.rows[0]) =>
colDims.map((d) => r.keys[d] || "").join(" — ");
// Series = first row dimension (or no stacking if no rows, or first row if columns exist)
const seriesDim = hasColDims ? rowDim : undefined;
// Collect unique x and series values
const xValues = hasColDims
? [...new Set(result.rows.map(getColKey))].sort()
: [...new Set(result.rows.map((r) => r.keys[rowDim]))].sort();
const seriesVals = seriesDim
? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort()
: [measure];
// Build chart data: one entry per x value
const data = xValues.map((xVal) => {
const entry: Record<string, string | number> = { name: xVal };
if (seriesDim) {
for (const sv of seriesVals) {
const matchingRows = result.rows.filter(
(r) => (hasColDims ? getColKey(r) : r.keys[rowDim]) === xVal && r.keys[seriesDim] === sv
);
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
} else {
const matchingRows = result.rows.filter((r) =>
hasColDims ? getColKey(r) === xVal : r.keys[rowDim] === xVal
);
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
return entry;
});
const colors: Record<string, string> = {};
seriesVals.forEach((sv, i) => {
colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length];
});
return { chartData: data, seriesKeys: seriesVals, seriesColors: colors };
}, [config, result]);
if (chartData.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("reports.pivot.noData")}</p>
</div>
);
}
const categoryEntries = seriesKeys.map((key, index) => ({
color: seriesColors[key],
index,
}));
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<ChartPatternDefs prefix="pivot-chart" categories={categoryEntries} />
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="name"
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
{seriesKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
stackId="stack"
fill={getPatternFill("pivot-chart", index, seriesColors[key])}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,306 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types";
import { getDynamicFilterValues } from "../../services/reportService";
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2", "level3"];
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
interface DynamicReportPanelProps {
config: PivotConfig;
onChange: (config: PivotConfig) => void;
}
export default function DynamicReportPanel({ config, onChange }: DynamicReportPanelProps) {
const { t } = useTranslation();
const [menuTarget, setMenuTarget] = useState<{ id: string; type: "field" | "measure"; x: number; y: number } | null>(null);
const [filterValues, setFilterValues] = useState<Record<string, string[]>>({});
const menuRef = useRef<HTMLDivElement>(null);
// A field is only "exhausted" if it's in all 3 zones (rows + columns + filters)
const inRows = new Set(config.rows);
const inColumns = new Set(config.columns);
const inFilters = new Set(Object.keys(config.filters) as PivotFieldId[]);
const assignedFields = new Set(
ALL_FIELDS.filter((f) => inRows.has(f) && inColumns.has(f) && inFilters.has(f))
);
const assignedMeasures = new Set(config.values);
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m));
// Load filter values when a field is added to filters
const filterFieldIds = Object.keys(config.filters) as PivotFieldId[];
useEffect(() => {
for (const fieldId of filterFieldIds) {
if (!filterValues[fieldId]) {
getDynamicFilterValues(fieldId as PivotFieldId).then((vals) => {
setFilterValues((prev) => ({ ...prev, [fieldId]: vals }));
});
}
}
}, [filterFieldIds.join(",")]);
// Close menu on outside click
useEffect(() => {
if (!menuTarget) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuTarget(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuTarget]);
const handleFieldClick = (id: string, type: "field" | "measure", e: React.MouseEvent) => {
setMenuTarget({ id, type, x: e.clientX, y: e.clientY });
};
const assignTo = useCallback((zone: PivotZone) => {
if (!menuTarget) return;
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
if (menuTarget.type === "measure") {
if (zone === "values") {
next.values = [...next.values, menuTarget.id as PivotMeasureId];
}
} else {
const fieldId = menuTarget.id as PivotFieldId;
if (zone === "rows") next.rows = [...next.rows, fieldId];
else if (zone === "columns") next.columns = [...next.columns, fieldId];
else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: { include: [], exclude: [] } };
}
setMenuTarget(null);
onChange(next);
}, [menuTarget, config, onChange]);
const removeFrom = (zone: PivotZone, id: string) => {
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
if (zone === "rows") next.rows = next.rows.filter((f) => f !== id);
else if (zone === "columns") next.columns = next.columns.filter((f) => f !== id);
else if (zone === "filters") {
const { [id]: _, ...rest } = next.filters;
next.filters = rest;
} else if (zone === "values") next.values = next.values.filter((m) => m !== id);
onChange(next);
};
const toggleFilterInclude = (fieldId: string, value: string) => {
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
const isIncluded = entry.include.includes(value);
const newInclude = isIncluded ? entry.include.filter((v) => v !== value) : [...entry.include, value];
// Remove from exclude if adding to include
const newExclude = isIncluded ? entry.exclude : entry.exclude.filter((v) => v !== value);
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
};
const toggleFilterExclude = (fieldId: string, value: string) => {
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
const isExcluded = entry.exclude.includes(value);
const newExclude = isExcluded ? entry.exclude.filter((v) => v !== value) : [...entry.exclude, value];
// Remove from include if adding to exclude
const newInclude = isExcluded ? entry.include : entry.include.filter((v) => v !== value);
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
};
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "level3" ? "level3" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
// Context menu only shows zones where the field is NOT already assigned
const getAvailableZones = (fieldId: string): PivotZone[] => {
const zones: PivotZone[] = [];
if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows");
if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns");
if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters");
return zones;
};
const zoneLabels: Record<PivotZone, string> = {
rows: t("reports.pivot.rows"),
columns: t("reports.pivot.columns"),
filters: t("reports.pivot.filters"),
values: t("reports.pivot.values"),
};
return (
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
{/* Available Fields */}
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-2">{t("reports.pivot.availableFields")}</h3>
<div className="flex flex-wrap gap-1.5">
{availableFields.map((f) => (
<button
key={f}
onClick={(e) => handleFieldClick(f, "field", e)}
className="px-2.5 py-1 rounded-lg bg-[var(--muted)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors text-xs"
>
{fieldLabel(f)}
</button>
))}
{availableMeasures.map((m) => (
<button
key={m}
onClick={(e) => handleFieldClick(m, "measure", e)}
className="px-2.5 py-1 rounded-lg bg-[var(--primary)]/10 text-[var(--primary)] hover:bg-[var(--primary)]/20 transition-colors text-xs"
>
{measureLabel(m)}
</button>
))}
{availableFields.length === 0 && availableMeasures.length === 0 && (
<span className="text-xs text-[var(--muted-foreground)]"></span>
)}
</div>
</div>
{/* Rows */}
<ZoneSection
label={t("reports.pivot.rows")}
items={config.rows}
getLabel={fieldLabel}
onRemove={(id) => removeFrom("rows", id)}
/>
{/* Columns */}
<ZoneSection
label={t("reports.pivot.columns")}
items={config.columns}
getLabel={fieldLabel}
onRemove={(id) => removeFrom("columns", id)}
/>
{/* Filters */}
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{t("reports.pivot.filters")}</h3>
{filterFieldIds.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="space-y-2">
{filterFieldIds.map((fieldId) => {
const entry = config.filters[fieldId] || { include: [], exclude: [] };
const hasActive = entry.include.length > 0 || entry.exclude.length > 0;
return (
<div key={fieldId}>
<div className="flex items-center gap-1 mb-1">
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
<button onClick={() => removeFrom("filters", fieldId)} className="text-[var(--muted-foreground)] hover:text-[var(--negative)]">
<X size={12} />
</button>
</div>
<div className="flex flex-wrap gap-1">
{(filterValues[fieldId] || []).map((val) => {
const isIncluded = entry.include.includes(val);
const isExcluded = entry.exclude.includes(val);
return (
<button
key={val}
onClick={() => toggleFilterInclude(fieldId, val)}
onContextMenu={(e) => {
e.preventDefault();
toggleFilterExclude(fieldId, val);
}}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
isIncluded
? "bg-[var(--primary)] text-white"
: isExcluded
? "bg-[var(--negative)] text-white line-through"
: hasActive
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
title={t("reports.pivot.rightClickExclude")}
>
{val}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Values */}
<ZoneSection
label={t("reports.pivot.values")}
items={config.values}
getLabel={measureLabel}
onRemove={(id) => removeFrom("values", id)}
accent
/>
{/* Context menu */}
{menuTarget && (
<div
ref={menuRef}
className="fixed z-50 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1 min-w-[140px]"
style={{ left: menuTarget.x, top: menuTarget.y }}
>
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
{menuTarget.type === "measure" ? (
<button
onClick={() => assignTo("values")}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{zoneLabels.values}
</button>
) : (
getAvailableZones(menuTarget.id).map((zone) => (
<button
key={zone}
onClick={() => assignTo(zone)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{zoneLabels[zone]}
</button>
))
)}
</div>
)}
</div>
);
}
function ZoneSection({
label,
items,
getLabel,
onRemove,
accent,
}: {
label: string;
items: string[];
getLabel: (id: string) => string;
onRemove: (id: string) => void;
accent?: boolean;
}) {
return (
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{label}</h3>
{items.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((id) => (
<span
key={id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs ${
accent
? "bg-[var(--primary)]/10 text-[var(--primary)]"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
>
{getLabel(id)}
<button onClick={() => onRemove(id)} className="hover:text-[var(--negative)]">
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
);
}

View file

@ -1,295 +0,0 @@
import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
const STORAGE_KEY = "pivot-subtotals-position";
interface DynamicReportTableProps {
config: PivotConfig;
result: PivotResult;
}
/** A pivoted row: one per unique combination of row dimensions */
interface PivotedRow {
rowKeys: Record<string, string>; // row-dimension values
cells: Record<string, Record<string, number>>; // colValue → measure → value
}
/** Pivot raw result rows into one PivotedRow per unique row-key combination */
function pivotRows(
rows: PivotResultRow[],
rowDims: string[],
colDims: string[],
measures: string[],
): PivotedRow[] {
const map = new Map<string, PivotedRow>();
for (const row of rows) {
const rowKey = rowDims.map((d) => row.keys[d] || "").join("\0");
let pivoted = map.get(rowKey);
if (!pivoted) {
const rowKeys: Record<string, string> = {};
for (const d of rowDims) rowKeys[d] = row.keys[d] || "";
pivoted = { rowKeys, cells: {} };
map.set(rowKey, pivoted);
}
const colKey = colDims.length > 0
? colDims.map((d) => row.keys[d] || "").join("\0")
: "__all__";
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
for (const m of measures) {
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
}
}
return Array.from(map.values());
}
interface GroupNode {
key: string;
label: string;
pivotedRows: PivotedRow[];
children: GroupNode[];
}
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
if (depth >= rowDims.length) return [];
const dim = rowDims[depth];
const map = new Map<string, PivotedRow[]>();
for (const row of rows) {
const key = row.rowKeys[dim] || "";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(row);
}
const groups: GroupNode[] = [];
for (const [key, groupRows] of map) {
groups.push({
key,
label: key,
pivotedRows: groupRows,
children: buildGroups(groupRows, rowDims, depth + 1),
});
}
return groups;
}
function computeSubtotals(
rows: PivotedRow[],
measures: string[],
colValues: string[],
): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {};
for (const colVal of colValues) {
result[colVal] = {};
for (const m of measures) {
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 0);
}
}
return result;
}
export default function DynamicReportTable({ config, result }: DynamicReportTableProps) {
const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "top";
});
const toggleSubtotals = () => {
setSubtotalsOnTop((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
return next;
});
};
const rowDims = config.rows;
const colDims = config.columns;
const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
const measures = config.values;
// Display label for a composite column key (joined with \0)
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
const pivotedRows = useMemo(
() => pivotRows(result.rows, rowDims, colDims, measures),
[result.rows, rowDims, colDims, measures],
);
if (pivotedRows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noData")}
</div>
);
}
const groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{rowDims.length > 1 && (
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
{rowDims.map((dim) => (
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{fieldLabel(dim)}
</th>
))}
{colValues.map((colVal) =>
measures.map((m) => (
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)}${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
</th>
))
)}
</tr>
</thead>
<tbody>
{rowDims.length === 0 ? (
<tr className="border-b border-[var(--border)]/50">
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
) : (
groups.map((group) => (
<GroupRows
key={group.key}
group={group}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={0}
subtotalsOnTop={subtotalsOnTop}
/>
))
)}
{/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-3">
{t("reports.pivot.total")}
</td>
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
</tbody>
</table>
</div>
</div>
);
}
function GroupRows({
group,
colValues,
measures,
rowDims,
depth,
subtotalsOnTop,
}: {
group: GroupNode;
colValues: string[];
measures: string[];
rowDims: string[];
depth: number;
subtotalsOnTop: boolean;
}) {
const isLeafLevel = depth === rowDims.length - 1;
const subtotals = computeSubtotals(group.pivotedRows, measures, colValues);
const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
<td className="px-3 py-1.5" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
{group.label}
</td>
{depth < rowDims.length - 1 && <td colSpan={rowDims.length - depth - 1} />}
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`sub-${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(subtotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
) : null;
if (isLeafLevel) {
// Render one table row per pivoted row (already deduplicated by row keys)
return (
<>
{group.pivotedRows.map((pRow, i) => (
<tr key={i} className="border-b border-[var(--border)]/50">
{rowDims.map((dim, di) => (
<td
key={dim}
className="px-3 py-1.5"
style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
>
{pRow.rowKeys[dim] || ""}
</td>
))}
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
))}
</>
);
}
const childContent = group.children.map((child) => (
<GroupRows
key={child.key}
group={child}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={depth + 1}
subtotalsOnTop={subtotalsOnTop}
/>
));
return (
<Fragment>
{subtotalsOnTop && subtotalRow}
{childContent}
{!subtotalsOnTop && subtotalRow}
</Fragment>
);
}

View file

@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import { BarChart, Bar, XAxis, YAxis, Cell, ReferenceLine, Tooltip, ResponsiveContainer } from "recharts";
import type { HighlightMover } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
export interface HighlightsTopMoversChartProps {
movers: HighlightMover[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
export default function HighlightsTopMoversChart({ movers }: HighlightsTopMoversChartProps) {
const { t, i18n } = useTranslation();
if (movers.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const chartData = movers
.map((m, i) => ({
name: m.categoryName,
color: m.categoryColor,
delta: m.deltaAbs,
index: i,
}))
.sort((a, b) => a.delta - b.delta);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={Math.max(200, chartData.length * 36 + 40)}>
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
<ChartPatternDefs
prefix="highlights-movers"
categories={chartData.map((d) => ({ color: d.color, index: d.index }))}
/>
<XAxis
type="number"
tickFormatter={(v) => formatCurrency(v, i18n.language)}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis type="category" dataKey="name" width={120} stroke="var(--muted-foreground)" fontSize={11} />
<ReferenceLine x={0} stroke="var(--border)" />
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Bar dataKey="delta">
{chartData.map((entry) => (
<Cell
key={entry.name}
fill={getPatternFill("highlights-movers", entry.index, entry.color)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,148 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { HighlightMover } from "../../shared/types";
type SortKey = "categoryName" | "previous" | "current" | "deltaAbs" | "deltaPct";
export interface HighlightsTopMoversTableProps {
movers: HighlightMover[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
export default function HighlightsTopMoversTable({ movers }: HighlightsTopMoversTableProps) {
const { t, i18n } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>("deltaAbs");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const sorted = [...movers].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "categoryName":
cmp = a.categoryName.localeCompare(b.categoryName);
break;
case "previous":
cmp = a.previousAmount - b.previousAmount;
break;
case "current":
cmp = a.currentAmount - b.currentAmount;
break;
case "deltaAbs":
cmp = Math.abs(a.deltaAbs) - Math.abs(b.deltaAbs);
break;
case "deltaPct":
cmp = (a.deltaPct ?? 0) - (b.deltaPct ?? 0);
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
}
const headerCell = (key: SortKey, label: string, align: "left" | "right") => (
<th
onClick={() => toggleSort(key)}
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
>
{label}
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
</th>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
{headerCell("categoryName", t("reports.highlights.category"), "left")}
{headerCell("previous", t("reports.highlights.previousAmount"), "right")}
{headerCell("current", t("reports.highlights.currentAmount"), "right")}
{headerCell("deltaAbs", t("reports.highlights.variationAbs"), "right")}
{headerCell("deltaPct", t("reports.highlights.variationPct"), "right")}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
sorted.map((mover) => (
<tr
key={`${mover.categoryId ?? "uncat"}-${mover.categoryName}`}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2">
<span className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: mover.categoryColor }}
/>
{mover.categoryName}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(mover.previousAmount, i18n.language)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(mover.currentAmount, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color:
mover.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatSignedCurrency(mover.deltaAbs, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums"
style={{
color:
mover.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatPct(mover.deltaPct, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,85 @@
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
export interface HighlightsTopTransactionsListProps {
transactions: RecentTransaction[];
windowDays: 30 | 60 | 90;
onWindowChange: (days: 30 | 60 | 90) => void;
onContextMenuRow?: (event: React.MouseEvent, transaction: RecentTransaction) => void;
}
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function HighlightsTopTransactionsList({
transactions,
windowDays,
onWindowChange,
onContextMenuRow,
}: HighlightsTopTransactionsListProps) {
const { t, i18n } = useTranslation();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<h3 className="text-sm font-semibold">{t("reports.highlights.topTransactions")}</h3>
<div className="flex gap-1">
{([30, 60, 90] as const).map((days) => (
<button
key={days}
type="button"
onClick={() => onWindowChange(days)}
aria-pressed={windowDays === days}
className={`text-xs px-2 py-1 rounded ${
windowDays === days
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
{t(`reports.highlights.windowDays${days}`)}
</button>
))}
</div>
</div>
{transactions.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</p>
) : (
<ul className="divide-y divide-[var(--border)]">
{transactions.map((tx) => (
<li
key={tx.id}
onContextMenu={onContextMenuRow ? (e) => onContextMenuRow(e, tx) : undefined}
className="flex items-center gap-3 px-4 py-2 text-sm"
>
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
{tx.category_name && (
<span className="text-xs text-[var(--muted-foreground)] flex-shrink-0">
{tx.category_name}
</span>
)}
<span
className="tabular-nums font-medium flex-shrink-0"
style={{ color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)" }}
>
{formatAmount(tx.amount, i18n.language)}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import type { HighlightsData } from "../../shared/types";
import HubNetBalanceTile from "./HubNetBalanceTile";
import HubTopMoversTile from "./HubTopMoversTile";
import HubTopTransactionsTile from "./HubTopTransactionsTile";
export interface HubHighlightsPanelProps {
data: HighlightsData | null;
isLoading: boolean;
error: string | null;
}
export default function HubHighlightsPanel({ data, isLoading, error }: HubHighlightsPanelProps) {
const { t } = useTranslation();
if (error) {
return (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
);
}
if (!data) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 mb-6 text-center text-[var(--muted-foreground)]">
{isLoading ? t("common.loading") : t("reports.empty.noData")}
</div>
);
}
const series = data.monthlyBalanceSeries.map((m) => m.netBalance);
return (
<section className={`mb-6 ${isLoading ? "opacity-60" : ""}`} aria-busy={isLoading}>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.hub.highlights")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<HubNetBalanceTile
label={t("reports.highlights.netBalanceCurrent")}
amount={data.netBalanceCurrent}
series={series}
/>
<HubNetBalanceTile
label={t("reports.highlights.netBalanceYtd")}
amount={data.netBalanceYtd}
series={series}
/>
<HubTopMoversTile movers={data.topMovers} />
<HubTopTransactionsTile transactions={data.topTransactions} />
</div>
</section>
);
}

View file

@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import Sparkline from "./Sparkline";
export interface HubNetBalanceTileProps {
label: string;
amount: number;
series: number[];
}
function formatSigned(amount: number, language: string): string {
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
}).format(amount);
return formatted;
}
export default function HubNetBalanceTile({ label, amount, series }: HubNetBalanceTileProps) {
const { i18n } = useTranslation();
const positive = amount >= 0;
const color = positive ? "var(--positive, #10b981)" : "var(--negative, #ef4444)";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{label}
</span>
<span className="text-2xl font-bold" style={{ color }}>
{formatSigned(amount, i18n.language)}
</span>
<Sparkline data={series} color={color} height={28} />
</div>
);
}

View file

@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
export interface HubReportNavCardProps {
to: string;
icon: ReactNode;
title: string;
description: string;
}
export default function HubReportNavCard({ to, icon, title, description }: HubReportNavCardProps) {
return (
<Link
to={to}
className="group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
>
<div className="text-[var(--primary)]">{icon}</div>
<h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]">
{title}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
</Link>
);
}

View file

@ -0,0 +1,105 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpRight, ArrowDownRight } from "lucide-react";
import type { HighlightMover } from "../../shared/types";
export interface HubTopMoversTileProps {
movers: HighlightMover[];
limit?: number;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(pct / 100);
}
export default function HubTopMoversTile({ movers, limit = 3 }: HubTopMoversTileProps) {
const { t, i18n } = useTranslation();
const [mode, setMode] = useState<"abs" | "pct">("abs");
const visible = movers.slice(0, limit);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.topMovers")}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setMode("abs")}
aria-pressed={mode === "abs"}
className={`text-xs px-2 py-0.5 rounded ${
mode === "abs"
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
$
</button>
<button
type="button"
onClick={() => setMode("pct")}
aria-pressed={mode === "pct"}
className={`text-xs px-2 py-0.5 rounded ${
mode === "pct"
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
%
</button>
</div>
</div>
{visible.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("reports.empty.noData")}</p>
) : (
<ul className="flex flex-col gap-1">
{visible.map((mover) => {
const isUp = mover.deltaAbs >= 0;
const Icon = isUp ? ArrowUpRight : ArrowDownRight;
const color = isUp ? "var(--negative, #ef4444)" : "var(--positive, #10b981)";
return (
<li
key={`${mover.categoryId ?? "uncat"}-${mover.categoryName}`}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: mover.categoryColor }}
/>
<span className="truncate">{mover.categoryName}</span>
</span>
<span className="flex items-center gap-1 flex-shrink-0" style={{ color }}>
<Icon size={14} />
<span className="tabular-nums font-medium">
{mode === "abs"
? formatCurrency(mover.deltaAbs, i18n.language)
: formatPct(mover.deltaPct, i18n.language)}
</span>
</span>
</li>
);
})}
</ul>
)}
<p className="text-[10px] text-[var(--muted-foreground)]">
{t("reports.highlights.vsLastMonth")}
</p>
</div>
);
}

View file

@ -0,0 +1,54 @@
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
export interface HubTopTransactionsTileProps {
transactions: RecentTransaction[];
limit?: number;
}
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function HubTopTransactionsTile({
transactions,
limit = 5,
}: HubTopTransactionsTileProps) {
const { t, i18n } = useTranslation();
const visible = transactions.slice(0, limit);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.topTransactions")}
</span>
{visible.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("reports.empty.noData")}</p>
) : (
<ul className="flex flex-col gap-1.5">
{visible.map((tx) => (
<li key={tx.id} className="flex items-center gap-2 text-sm">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="truncate flex-1 min-w-0">{tx.description}</span>
<span
className="tabular-nums font-medium flex-shrink-0"
style={{ color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)" }}
>
{formatAmount(tx.amount, i18n.language)}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -37,7 +37,7 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.pivot.month")}
{t("reports.month")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.income")}

View file

@ -1,166 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, Search } from "lucide-react";
import type { ImportSource } from "../../shared/types";
import type { CategoryTypeFilter } from "../../hooks/useReports";
interface ReportFilterPanelProps {
categories: { name: string; color: string }[];
hiddenCategories: Set<string>;
onToggleHidden: (name: string) => void;
onShowAll: () => void;
sources: ImportSource[];
selectedSourceId: number | null;
onSourceChange: (id: number | null) => void;
categoryType?: CategoryTypeFilter;
onCategoryTypeChange?: (type: CategoryTypeFilter) => void;
}
export default function ReportFilterPanel({
categories,
hiddenCategories,
onToggleHidden,
onShowAll,
sources,
selectedSourceId,
onSourceChange,
categoryType,
onCategoryTypeChange,
}: ReportFilterPanelProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState(false);
const filtered = search
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: categories;
const allVisible = hiddenCategories.size === 0;
const allHidden = hiddenCategories.size === categories.length;
return (
<div className="w-56 shrink-0 sticky top-4 self-start space-y-3">
{/* Source filter */}
{sources.length > 1 && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("transactions.table.source")}
</div>
<div className="border-t border-[var(--border)] px-2 py-2">
<select
value={selectedSourceId ?? ""}
onChange={(e) => onSourceChange(e.target.value ? Number(e.target.value) : null)}
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("transactions.filters.allSources")}</option>
{sources.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
</div>
)}
{/* Type filter */}
{onCategoryTypeChange && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("categories.type")}
</div>
<div className="border-t border-[var(--border)] px-2 py-2">
<select
value={categoryType ?? ""}
onChange={(e) => {
const v = e.target.value;
const valid: CategoryTypeFilter[] = ["expense", "income", "transfer"];
onCategoryTypeChange(valid.includes(v as CategoryTypeFilter) ? (v as CategoryTypeFilter) : null);
}}
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("reports.filters.allTypes")}</option>
<option value="expense">{t("categories.expense")}</option>
<option value="income">{t("categories.income")}</option>
<option value="transfer">{t("categories.transfer")}</option>
</select>
</div>
</div>
)}
{/* Category filter */}
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm font-medium text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("reports.filters.title")}
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
{categories.length - hiddenCategories.size}/{categories.length}
</span>
</button>
{!collapsed && (
<div className="border-t border-[var(--border)]">
<div className="px-2 py-2">
<div className="relative">
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("reports.filters.search")}
className="w-full pl-7 pr-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
</div>
<div className="px-2 pb-1 flex gap-1">
<button
onClick={onShowAll}
disabled={allVisible}
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
>
{t("reports.filters.all")}
</button>
<button
onClick={() => categories.forEach((c) => { if (!hiddenCategories.has(c.name)) onToggleHidden(c.name); })}
disabled={allHidden}
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
>
{t("reports.filters.none")}
</button>
</div>
<div className="max-h-64 overflow-y-auto px-2 pb-2 space-y-0.5">
{filtered.map((cat) => {
const visible = !hiddenCategories.has(cat.name);
return (
<label
key={cat.name}
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-[var(--muted)] cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={visible}
onChange={() => onToggleHidden(cat.name)}
className="rounded border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)] h-3.5 w-3.5"
/>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
<span className={`text-xs truncate ${visible ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)] line-through"}`}>
{cat.name}
</span>
</label>
);
})}
</div>
</div>
)}
</div>}
</div>
);
}

View file

@ -0,0 +1,39 @@
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
export interface SparklineProps {
data: number[];
color?: string;
width?: number | `${number}%`;
height?: number;
strokeWidth?: number;
}
export default function Sparkline({
data,
color = "var(--primary)",
width = "100%",
height = 32,
strokeWidth = 1.5,
}: SparklineProps) {
if (data.length === 0) {
return <div style={{ width, height }} />;
}
const chartData = data.map((value, index) => ({ index, value }));
return (
<ResponsiveContainer width={width} height={height}>
<LineChart data={chartData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={strokeWidth}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readViewMode } from "./ViewModeToggle";
describe("readViewMode", () => {
const store = new Map<string, string>();
const mockLocalStorage = {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => store.clear()),
key: vi.fn(),
length: 0,
};
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", mockLocalStorage);
});
it("returns fallback when key is missing", () => {
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
});
it("returns 'chart' when stored value is 'chart'", () => {
store.set("reports-viewmode-highlights", "chart");
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
});
it("returns 'table' when stored value is 'table'", () => {
store.set("reports-viewmode-highlights", "table");
expect(readViewMode("reports-viewmode-highlights")).toBe("table");
});
it("ignores invalid stored values and returns fallback", () => {
store.set("reports-viewmode-highlights", "bogus");
expect(readViewMode("reports-viewmode-highlights", "table")).toBe("table");
});
it("respects custom fallback when provided", () => {
expect(readViewMode("reports-viewmode-compare", "table")).toBe("table");
});
});

View file

@ -0,0 +1,52 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Table } from "lucide-react";
export type ViewMode = "chart" | "table";
export interface ViewModeToggleProps {
value: ViewMode;
onChange: (mode: ViewMode) => void;
/** localStorage key used to persist the preference per section. */
storageKey?: string;
}
export function readViewMode(storageKey: string, fallback: ViewMode = "chart"): ViewMode {
if (typeof localStorage === "undefined") return fallback;
const saved = localStorage.getItem(storageKey);
return saved === "chart" || saved === "table" ? saved : fallback;
}
export default function ViewModeToggle({ value, onChange, storageKey }: ViewModeToggleProps) {
const { t } = useTranslation();
useEffect(() => {
if (storageKey) localStorage.setItem(storageKey, value);
}, [value, storageKey]);
const options: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.viewMode.chart") },
{ mode: "table", icon: <Table size={14} />, label: t("reports.viewMode.table") },
];
return (
<div className="inline-flex gap-1" role="group" aria-label={t("reports.viewMode.chart")}>
{options.map(({ mode, icon, label }) => (
<button
key={mode}
type="button"
onClick={() => onChange(mode)}
aria-pressed={value === mode}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === mode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,111 @@
import { useTranslation } from "react-i18next";
import { Target } from "lucide-react";
import type { CartesBudgetAdherence } from "../../../shared/types";
export interface BudgetAdherenceCardProps {
adherence: CartesBudgetAdherence;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(pct / 100);
}
export default function BudgetAdherenceCard({ adherence }: BudgetAdherenceCardProps) {
const { t, i18n } = useTranslation();
const { categoriesInTarget, categoriesTotal, worstOverruns } = adherence;
const score = categoriesTotal === 0 ? null : (categoriesInTarget / categoriesTotal) * 100;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Target size={16} className="text-[var(--primary)]" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
{t("reports.cartes.budgetAdherenceTitle")}
</h3>
</div>
{categoriesTotal === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.cartes.budgetAdherenceEmpty")}
</div>
) : (
<>
<div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{categoriesInTarget}
<span className="text-sm text-[var(--muted-foreground)] font-normal">
{" / "}
{categoriesTotal}
</span>
</div>
<div className="text-xs text-[var(--muted-foreground)]">
{t("reports.cartes.budgetAdherenceSubtitle", {
score:
score !== null
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
}).format(score / 100)
: "—",
})}
</div>
</div>
{worstOverruns.length > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-[var(--border)]">
<div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{t("reports.cartes.budgetAdherenceWorst")}
</div>
{worstOverruns.map((r) => {
const progressPct = r.budget > 0 ? Math.min((r.actual / r.budget) * 100, 200) : 0;
return (
<div key={r.categoryId} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: r.categoryColor }}
/>
<span className="truncate text-[var(--foreground)]">{r.categoryName}</span>
</span>
<span className="tabular-nums text-[var(--muted-foreground)]">
{formatCurrency(r.actual, i18n.language)}
{" / "}
{formatCurrency(r.budget, i18n.language)}
<span className="text-[var(--negative)] ml-1 font-medium">
{formatPct(r.overrunPct, i18n.language)}
</span>
</span>
</div>
<div
className="h-1.5 rounded-full bg-[var(--muted)] overflow-hidden"
aria-hidden
>
<div
className="h-full bg-[var(--negative)]"
style={{ width: `${Math.min(progressPct, 100)}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,97 @@
import { useTranslation } from "react-i18next";
import {
ComposedChart,
Bar,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
} from "recharts";
import type { CartesMonthFlow } from "../../../shared/types";
export interface IncomeExpenseOverlayChartProps {
flow: CartesMonthFlow[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatMonthShort(month: string, language: string): string {
const [y, m] = month.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return month;
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "short",
year: "2-digit",
}).format(new Date(y, m - 1, 1));
}
export default function IncomeExpenseOverlayChart({ flow }: IncomeExpenseOverlayChartProps) {
const { t, i18n } = useTranslation();
if (flow.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const data = flow.map((p) => ({
...p,
label: formatMonthShort(p.month, i18n.language),
}));
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<div className="text-sm font-medium text-[var(--foreground)] mb-3">
{t("reports.cartes.flowChartTitle")}
</div>
<ResponsiveContainer width="100%" height={260}>
<ComposedChart data={data} margin={{ top: 10, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="label" stroke="var(--muted-foreground)" fontSize={11} />
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v) => formatCurrency(v, i18n.language)}
width={80}
/>
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<ReferenceLine y={0} stroke="var(--border)" />
<Bar dataKey="income" name={t("reports.cartes.income")} fill="var(--positive)" />
<Bar dataKey="expenses" name={t("reports.cartes.expenses")} fill="var(--negative)" />
<Line
type="monotone"
dataKey="net"
name={t("reports.cartes.net")}
stroke="var(--primary)"
strokeWidth={2}
dot={{ r: 2 }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,139 @@
import { useTranslation } from "react-i18next";
import KpiSparkline from "./KpiSparkline";
import type { CartesKpi, CartesKpiId } from "../../../shared/types";
export interface KpiCardProps {
id: CartesKpiId;
title: string;
kpi: CartesKpi;
format: "currency" | "percent";
/** When true, positive deltas are rendered in red (e.g. rising expenses). */
deltaIsBadWhenUp?: boolean;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPercent(value: number, language: string, signed = false): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: signed ? "always" : "auto",
}).format(value / 100);
}
function formatValue(value: number, format: "currency" | "percent", language: string): string {
return format === "currency" ? formatCurrency(value, language) : formatPercent(value, language);
}
function formatDeltaAbs(
value: number,
format: "currency" | "percent",
language: string,
): string {
if (format === "currency") {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(value);
}
// Savings rate delta in percentage points — not a % of %
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
maximumFractionDigits: 1,
signDisplay: "always",
}).format(value);
return `${formatted} pt`;
}
interface DeltaBadgeProps {
abs: number | null;
pct: number | null;
label: string;
format: "currency" | "percent";
language: string;
deltaIsBadWhenUp: boolean;
}
function DeltaBadge({ abs, pct, label, format, language, deltaIsBadWhenUp }: DeltaBadgeProps) {
if (abs === null) {
return (
<div className="flex flex-col items-start">
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</span>
<span className="text-xs text-[var(--muted-foreground)] italic"></span>
</div>
);
}
const isUp = abs >= 0;
const isBad = deltaIsBadWhenUp ? isUp : !isUp;
// Treat near-zero as neutral
const isNeutral = abs === 0;
const colorClass = isNeutral
? "text-[var(--muted-foreground)]"
: isBad
? "text-[var(--negative)]"
: "text-[var(--positive)]";
const absText = formatDeltaAbs(abs, format, language);
const pctText = pct === null ? "" : ` (${formatPercent(pct, language, true)})`;
return (
<div className="flex flex-col items-start">
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</span>
<span className={`text-xs font-medium tabular-nums ${colorClass}`}>
{absText}
{pctText}
</span>
</div>
);
}
export default function KpiCard({
id,
title,
kpi,
format,
deltaIsBadWhenUp = false,
}: KpiCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.language;
return (
<div
data-kpi={id}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3"
>
<div className="text-sm text-[var(--muted-foreground)]">{title}</div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{formatValue(kpi.current, format, language)}
</div>
<KpiSparkline data={kpi.sparkline} />
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">
<DeltaBadge
abs={kpi.deltaMoMAbs}
pct={kpi.deltaMoMPct}
label={t("reports.cartes.deltaMoMLabel")}
format={format}
language={language}
deltaIsBadWhenUp={deltaIsBadWhenUp}
/>
<DeltaBadge
abs={kpi.deltaYoYAbs}
pct={kpi.deltaYoYPct}
label={t("reports.cartes.deltaYoYLabel")}
format={format}
language={language}
deltaIsBadWhenUp={deltaIsBadWhenUp}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
import { LineChart, Line, ResponsiveContainer, YAxis, ReferenceDot } from "recharts";
import type { CartesSparklinePoint } from "../../../shared/types";
export interface KpiSparklineProps {
data: CartesSparklinePoint[];
color?: string;
height?: number;
}
/**
* Compact line chart with the reference month (the last point) highlighted
* by a filled dot. Rendered inside the KPI cards on the Cartes page.
*/
export default function KpiSparkline({
data,
color = "var(--primary)",
height = 40,
}: KpiSparklineProps) {
if (data.length === 0) {
return <div style={{ width: "100%", height }} />;
}
const chartData = data.map((p, index) => ({ index, value: p.value, month: p.month }));
const lastIndex = chartData.length - 1;
const lastValue = chartData[lastIndex]?.value ?? 0;
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={chartData} margin={{ top: 4, right: 6, bottom: 2, left: 2 }}>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.75}
dot={false}
isAnimationActive={false}
/>
<ReferenceDot
x={lastIndex}
y={lastValue}
r={3.5}
fill={color}
stroke="var(--card)"
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,106 @@
import { useTranslation } from "react-i18next";
import { CalendarClock } from "lucide-react";
import type { CartesSeasonality } from "../../../shared/types";
export interface SeasonalityCardProps {
seasonality: CartesSeasonality;
referenceYear: number;
referenceMonth: number;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number, language: string, signed = true): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: signed ? "always" : "auto",
}).format(pct / 100);
}
function formatMonthYear(year: number, month: number, language: string): string {
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(new Date(year, month - 1, 1));
}
export default function SeasonalityCard({
seasonality,
referenceYear,
referenceMonth,
}: SeasonalityCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.language;
const { referenceAmount, historicalYears, historicalAverage, deviationPct } = seasonality;
const refLabel = formatMonthYear(referenceYear, referenceMonth, language);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<CalendarClock size={16} className="text-[var(--primary)]" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
{t("reports.cartes.seasonalityTitle")}
</h3>
</div>
{historicalYears.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.cartes.seasonalityEmpty")}
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-[var(--muted-foreground)]">{refLabel}</span>
<span className="text-lg font-bold tabular-nums text-[var(--foreground)]">
{formatCurrency(referenceAmount, language)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs">
{historicalYears.map((y) => (
<div
key={y.year}
className="flex items-center justify-between text-[var(--muted-foreground)]"
>
<span>{y.year}</span>
<span className="tabular-nums">{formatCurrency(y.amount, language)}</span>
</div>
))}
{historicalAverage !== null && (
<div className="flex items-center justify-between border-t border-[var(--border)] pt-1 mt-1 text-[var(--foreground)]">
<span>{t("reports.cartes.seasonalityAverage")}</span>
<span className="tabular-nums font-medium">
{formatCurrency(historicalAverage, language)}
</span>
</div>
)}
</div>
{deviationPct !== null && (
<div
className={`text-xs font-medium ${
deviationPct > 5
? "text-[var(--negative)]"
: deviationPct < -5
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
>
{t("reports.cartes.seasonalityDeviation", {
pct: formatPct(deviationPct, language),
})}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,86 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TrendingUp, TrendingDown } from "lucide-react";
import type { CartesTopMover } from "../../../shared/types";
export interface TopMoversListProps {
movers: CartesTopMover[];
direction: "up" | "down";
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
function categoryHref(categoryId: number | null): string {
if (categoryId === null) return "/transactions";
const params = new URLSearchParams(window.location.search);
params.set("cat", String(categoryId));
return `/reports/category?${params.toString()}`;
}
export default function TopMoversList({ movers, direction }: TopMoversListProps) {
const { t, i18n } = useTranslation();
const title =
direction === "up"
? t("reports.cartes.topMoversUp")
: t("reports.cartes.topMoversDown");
const Icon = direction === "up" ? TrendingUp : TrendingDown;
const accentClass = direction === "up" ? "text-[var(--negative)]" : "text-[var(--positive)]";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Icon size={16} className={accentClass} />
<h3 className="text-sm font-medium text-[var(--foreground)]">{title}</h3>
</div>
{movers.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.empty.noData")}
</div>
) : (
<ul className="flex flex-col gap-1">
{movers.map((m) => (
<li key={`${m.categoryId ?? "uncat"}-${m.categoryName}`}>
<Link
to={categoryHref(m.categoryId)}
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] transition-colors"
>
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: m.categoryColor }}
/>
<span className="truncate text-sm text-[var(--foreground)]">
{m.categoryName}
</span>
</span>
<span className={`text-xs font-medium tabular-nums ${accentClass}`}>
{formatSignedCurrency(m.deltaAbs, i18n.language)}
<span className="text-[var(--muted-foreground)] ml-1">
{formatPct(m.deltaPct, i18n.language)}
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,79 @@
import { useTranslation } from "react-i18next";
import { User, LogIn, LogOut, Loader2, AlertCircle } from "lucide-react";
import { useAuth } from "../../hooks/useAuth";
export default function AccountCard() {
const { t } = useTranslation();
const { state, login, logout } = useAuth();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<User size={18} />
{t("account.title")}
<span className="text-xs font-normal text-[var(--muted-foreground)]">
{t("account.optional")}
</span>
</h2>
{state.status === "error" && state.error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p>
</div>
)}
{state.status === "authenticated" && state.account && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[var(--primary)] text-white flex items-center justify-center font-semibold text-sm">
{(state.account.name || state.account.email).charAt(0).toUpperCase()}
</div>
<div>
<p className="font-medium">
{state.account.name || state.account.email}
</p>
{state.account.name && (
<p className="text-sm text-[var(--muted-foreground)]">
{state.account.email}
</p>
)}
</div>
</div>
<button
type="button"
onClick={logout}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
<LogOut size={14} />
{t("account.signOut")}
</button>
</div>
)}
{(state.status === "unauthenticated" || state.status === "idle") && (
<div className="space-y-3">
<p className="text-sm text-[var(--muted-foreground)]">
{t("account.description")}
</p>
<button
type="button"
onClick={login}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
>
<LogIn size={14} />
{t("account.signIn")}
</button>
</div>
)}
{state.status === "loading" && (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 size={14} className="animate-spin" />
{t("common.loading")}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Globe } from "lucide-react";
interface FeedbackConsentDialogProps {
onAccept: () => void;
onCancel: () => void;
}
export default function FeedbackConsentDialog({
onAccept,
onCancel,
}: FeedbackConsentDialogProps) {
const { t } = useTranslation();
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onCancel]);
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Globe size={18} />
{t("feedback.consent.title")}
</h2>
<button
onClick={onCancel}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
aria-label={t("feedback.dialog.cancel")}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4 space-y-3 text-sm text-[var(--muted-foreground)]">
<p>{t("feedback.consent.body")}</p>
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
<button
onClick={onCancel}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
{t("feedback.dialog.cancel")}
</button>
<button
onClick={onAccept}
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
{t("feedback.consent.accept")}
</button>
</div>
</div>
</div>,
document.body,
);
}

View file

@ -0,0 +1,239 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { X, MessageSquarePlus, CheckCircle, AlertCircle } from "lucide-react";
import { useFeedback } from "../../hooks/useFeedback";
import { useAuth } from "../../hooks/useAuth";
import { getRecentErrorLogs } from "../../services/logService";
import {
getFeedbackUserAgent,
type FeedbackContext,
} from "../../services/feedbackService";
const MAX_CONTENT_LENGTH = 2000;
const LOGS_SUFFIX_MAX = 800;
const RECENT_ERROR_LOGS_N = 20;
const AUTO_CLOSE_DELAY_MS = 2000;
interface FeedbackDialogProps {
onClose: () => void;
}
export default function FeedbackDialog({ onClose }: FeedbackDialogProps) {
const { t, i18n } = useTranslation();
const location = useLocation();
const { state: authState } = useAuth();
const { state: feedbackState, submit, reset } = useFeedback();
const [content, setContent] = useState("");
const [includeContext, setIncludeContext] = useState(false);
const [includeLogs, setIncludeLogs] = useState(false);
const [identify, setIdentify] = useState(false);
const isAuthenticated = authState.status === "authenticated";
const userEmail = authState.account?.email ?? null;
const trimmed = content.trim();
const isSending = feedbackState.status === "sending";
const isSuccess = feedbackState.status === "success";
const canSubmit = trimmed.length > 0 && !isSending && !isSuccess;
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape" && !isSending) onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose, isSending]);
// Auto-close after success
useEffect(() => {
if (!isSuccess) return;
const timer = setTimeout(() => {
onClose();
reset();
}, AUTO_CLOSE_DELAY_MS);
return () => clearTimeout(timer);
}, [isSuccess, onClose, reset]);
const errorMessage = useMemo(() => {
if (feedbackState.status !== "error" || !feedbackState.errorCode) return null;
switch (feedbackState.errorCode) {
case "rate_limit":
return t("feedback.toast.error.429");
case "invalid":
return t("feedback.toast.error.400");
case "network_error":
case "server_error":
default:
return t("feedback.toast.error.generic");
}
}, [feedbackState, t]);
async function handleSubmit() {
if (!canSubmit) return;
// Compose content with optional logs suffix, keeping total ≤ 2000 chars
let body = trimmed;
if (includeLogs) {
const rawLogs = getRecentErrorLogs(RECENT_ERROR_LOGS_N);
if (rawLogs.length > 0) {
const trimmedLogs = rawLogs.slice(-LOGS_SUFFIX_MAX);
const suffix = `\n\n---\n${t("feedback.logsHeading")}\n${trimmedLogs}`;
const available = MAX_CONTENT_LENGTH - body.length;
if (available > suffix.length) {
body = body + suffix;
} else if (available > 50) {
body = body + suffix.slice(0, available);
}
}
}
// Compose context if opted in
let context: FeedbackContext | undefined;
if (includeContext) {
let userAgent = "Simpl'Résultat";
try {
userAgent = await getFeedbackUserAgent();
} catch {
// fall back to a basic string if the Rust helper fails
}
context = {
page: location.pathname,
locale: i18n.language,
theme: document.documentElement.classList.contains("dark") ? "dark" : "light",
viewport: `${window.innerWidth}x${window.innerHeight}`,
userAgent,
timestamp: new Date().toISOString(),
};
}
const userId = identify && isAuthenticated ? userEmail : null;
await submit({ content: body, userId, context });
}
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget && !isSending) onClose();
}}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold flex items-center gap-2">
<MessageSquarePlus size={18} />
{t("feedback.dialog.title")}
</h2>
<button
onClick={onClose}
disabled={isSending}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-50"
aria-label={t("feedback.dialog.cancel")}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4 space-y-3">
{isSuccess ? (
<div className="flex items-center gap-2 text-[var(--positive)] py-8 justify-center">
<CheckCircle size={20} />
<span>{t("feedback.toast.success")}</span>
</div>
) : (
<>
<textarea
value={content}
onChange={(e) =>
setContent(e.target.value.slice(0, MAX_CONTENT_LENGTH))
}
placeholder={t("feedback.dialog.placeholder")}
rows={6}
disabled={isSending}
className="w-full px-3 py-2 rounded-lg bg-[var(--background)] border border-[var(--border)] text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--primary)] disabled:opacity-50"
/>
<div className="flex justify-end text-xs text-[var(--muted-foreground)]">
{content.length}/{MAX_CONTENT_LENGTH}
</div>
<div className="space-y-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeContext}
onChange={(e) => setIncludeContext(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>{t("feedback.checkbox.context")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeLogs}
onChange={(e) => setIncludeLogs(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>{t("feedback.checkbox.logs")}</span>
</label>
{isAuthenticated && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={identify}
onChange={(e) => setIdentify(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>
{t("feedback.checkbox.identify")}
{userEmail && (
<span className="text-[var(--muted-foreground)]">
{" "}
({userEmail})
</span>
)}
</span>
</label>
)}
</div>
{errorMessage && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
</>
)}
</div>
{!isSuccess && (
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
<button
onClick={onClose}
disabled={isSending}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{t("feedback.dialog.cancel")}
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isSending
? t("feedback.dialog.sending")
: t("feedback.dialog.submit")}
</button>
</div>
)}
</div>
</div>,
document.body,
);
}

View file

@ -1,8 +1,16 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink } from "lucide-react";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
import { useLicense } from "../../hooks/useLicense";
import {
MachineInfo,
ActivationStatus,
activateMachine,
deactivateMachine,
listActivatedMachines,
getActivationStatus,
} from "../../services/licenseService";
const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
@ -11,6 +19,75 @@ export default function LicenseCard() {
const { state, submitKey } = useLicense();
const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false);
const [showMachines, setShowMachines] = useState(false);
const [machines, setMachines] = useState<MachineInfo[]>([]);
const [activation, setActivation] = useState<ActivationStatus | null>(null);
const [machineLoading, setMachineLoading] = useState(false);
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
const [machineError, setMachineError] = useState<string | null>(null);
const hasLicense = state.edition !== "free";
const loadActivation = useCallback(async () => {
if (!hasLicense) return;
try {
const status = await getActivationStatus();
setActivation(status);
} catch {
// Ignore — activation status is best-effort
}
}, [hasLicense]);
const loadMachines = useCallback(async () => {
setMachineLoading(true);
setMachineError(null);
try {
const list = await listActivatedMachines();
setMachines(list);
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setMachineLoading(false);
}
}, []);
useEffect(() => {
void loadActivation();
}, [loadActivation]);
const handleActivate = async () => {
setMachineLoading(true);
setMachineError(null);
try {
await activateMachine();
await loadActivation();
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setMachineLoading(false);
}
};
const handleDeactivate = async (machineId: string) => {
setDeactivatingId(machineId);
try {
await deactivateMachine(machineId);
await loadActivation();
await loadMachines();
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setDeactivatingId(null);
}
};
const toggleMachines = async () => {
const next = !showMachines;
setShowMachines(next);
if (next && machines.length === 0) {
await loadMachines();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -123,6 +200,100 @@ export default function LicenseCard() {
</div>
</form>
)}
{hasLicense && (
<div className="border-t border-[var(--border)] pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Monitor size={16} />
{t("license.machines.title")}
</h3>
<div className="flex items-center gap-2">
{activation && !activation.is_activated && (
<button
type="button"
onClick={handleActivate}
disabled={machineLoading}
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
>
{machineLoading && <Loader2 size={12} className="animate-spin" />}
{t("license.activate")}
</button>
)}
{activation?.is_activated && (
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
<CheckCircle size={12} />
{t("license.machines.activated")}
</span>
)}
<button
type="button"
onClick={toggleMachines}
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
>
{showMachines ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
{machineError && (
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<p>{machineError}</p>
</div>
)}
{showMachines && (
<div className="space-y-2">
{machineLoading && machines.length === 0 && (
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<Loader2 size={12} className="animate-spin" />
{t("common.loading")}
</div>
)}
{!machineLoading && machines.length === 0 && (
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.machines.noMachines")}
</p>
)}
{machines.map((m) => {
const isThis = activation?.machine_id === m.machine_id;
return (
<div
key={m.machine_id}
className="flex items-center justify-between px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm"
>
<div>
<span className="font-medium">
{m.machine_name || m.machine_id.slice(0, 12)}
</span>
{isThis && (
<span className="ml-2 text-xs text-[var(--positive)]">
({t("license.machines.thisMachine")})
</span>
)}
<p className="text-xs text-[var(--muted-foreground)]">
{new Date(m.activated_at).toLocaleDateString()}
</p>
</div>
<button
type="button"
onClick={() => handleDeactivate(m.machine_id)}
disabled={deactivatingId === m.machine_id}
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{deactivatingId === m.machine_id && (
<Loader2 size={10} className="animate-spin" />
)}
{t("license.machines.deactivate")}
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -1,16 +1,37 @@
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { ScrollText, Trash2, Copy, Check } from "lucide-react";
import { ScrollText, Trash2, Copy, Check, MessageSquarePlus } from "lucide-react";
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
import FeedbackDialog from "./FeedbackDialog";
import FeedbackConsentDialog from "./FeedbackConsentDialog";
type Filter = "all" | LogLevel;
const FEEDBACK_CONSENT_KEY = "feedbackConsentAccepted";
export default function LogViewerCard() {
const { t } = useTranslation();
const [filter, setFilter] = useState<Filter>("all");
const [copied, setCopied] = useState(false);
const [consentOpen, setConsentOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const openFeedback = () => {
const accepted = localStorage.getItem(FEEDBACK_CONSENT_KEY) === "true";
if (accepted) {
setFeedbackOpen(true);
} else {
setConsentOpen(true);
}
};
const acceptConsent = () => {
localStorage.setItem(FEEDBACK_CONSENT_KEY, "true");
setConsentOpen(false);
setFeedbackOpen(true);
};
const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
@ -54,6 +75,13 @@ export default function LogViewerCard() {
{t("settings.logs.title")}
</h2>
<div className="flex items-center gap-2">
<button
onClick={openFeedback}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
<MessageSquarePlus size={14} />
{t("feedback.button")}
</button>
<button
onClick={handleCopy}
disabled={filtered.length === 0}
@ -111,6 +139,14 @@ export default function LogViewerCard() {
))
)}
</div>
{consentOpen && (
<FeedbackConsentDialog
onAccept={acceptConsent}
onCancel={() => setConsentOpen(false)}
/>
)}
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
</div>
);
}

View file

@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert, X } from "lucide-react";
import { getTokenStoreMode, TokenStoreMode } from "../../services/authService";
// Per-session dismissal flag. Kept in sessionStorage so the banner
// returns on the next app launch if the fallback condition still
// holds — this matches the acceptance criteria from issue #81.
const DISMISS_KEY = "tokenStoreFallbackBannerDismissed";
export default function TokenStoreFallbackBanner() {
const { t } = useTranslation();
const [mode, setMode] = useState<TokenStoreMode | null>(null);
const [dismissed, setDismissed] = useState<boolean>(() => {
try {
return sessionStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
let cancelled = false;
getTokenStoreMode()
.then((m) => {
if (!cancelled) setMode(m);
})
.catch(() => {
if (!cancelled) setMode(null);
});
return () => {
cancelled = true;
};
}, []);
if (mode !== "file" || dismissed) return null;
const dismiss = () => {
try {
sessionStorage.setItem(DISMISS_KEY, "1");
} catch {
// Ignore storage errors — the banner will simply hide for the
// remainder of this render cycle via state.
}
setDismissed(true);
};
return (
<div className="flex items-start gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4">
<ShieldAlert size={20} className="mt-0.5 shrink-0 text-amber-500" />
<div className="flex-1 space-y-1 text-sm">
<p className="font-semibold text-[var(--foreground)]">
{t("account.tokenStore.fallback.title")}
</p>
<p className="text-[var(--muted-foreground)]">
{t("account.tokenStore.fallback.description")}
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("common.close")}
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
</button>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { EyeOff, List } from "lucide-react";
import ContextMenu from "./ContextMenu";
export interface ChartContextMenuProps {
x: number;
@ -20,60 +20,25 @@ export default function ChartContextMenu({
onClose,
}: ChartContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
// Adjust position to stay within viewport
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
style={{ left: x, top: y }}
>
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{categoryName}
</div>
<button
onClick={() => { onViewDetails(); onClose(); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<List size={14} />
{t("charts.viewTransactions")}
</button>
<button
onClick={() => { onHide(); onClose(); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<EyeOff size={14} />
{t("charts.hideCategory")}
</button>
</div>
<ContextMenu
x={x}
y={y}
header={categoryName}
onClose={onClose}
items={[
{
icon: <List size={14} />,
label: t("charts.viewTransactions"),
onClick: onViewDetails,
},
{
icon: <EyeOff size={14} />,
label: t("charts.hideCategory"),
onClick: onHide,
},
]}
/>
);
}

View file

@ -0,0 +1,77 @@
import { useEffect, useRef, type ReactNode } from "react";
export interface ContextMenuItem {
icon?: ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
header?: ReactNode;
items: ContextMenuItem[];
onClose: () => void;
}
export default function ContextMenu({ x, y, header, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
style={{ left: x, top: y }}
>
{header && (
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{header}
</div>
)}
{items.map((item, i) => (
<button
key={i}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick();
onClose();
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{item.icon}
{item.label}
</button>
))}
</div>
);
}

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
import { check } from "@tauri-apps/plugin-updater";
import { invoke } from "@tauri-apps/api/core";
interface ErrorPageProps {
error?: string;
@ -10,7 +11,7 @@ interface ErrorPageProps {
export default function ErrorPage({ error }: ErrorPageProps) {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "error">("idle");
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "notEntitled" | "error">("idle");
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
@ -18,6 +19,13 @@ export default function ErrorPage({ error }: ErrorPageProps) {
setUpdateStatus("checking");
setUpdateError(null);
try {
const allowed = await invoke<boolean>("check_entitlement", {
feature: "auto-update",
});
if (!allowed) {
setUpdateStatus("notEntitled");
return;
}
const update = await check();
if (update) {
setUpdateStatus("available");
@ -89,6 +97,11 @@ export default function ErrorPage({ error }: ErrorPageProps) {
{t("error.upToDate")}
</p>
)}
{updateStatus === "notEntitled" && (
<p className="text-sm text-[var(--muted-foreground)]">
{t("error.updateNotEntitled")}
</p>
)}
{updateStatus === "error" && updateError && (
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
)}

View file

@ -21,6 +21,7 @@ interface TransactionTableProps {
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
}
function SortIcon({
@ -50,6 +51,7 @@ export default function TransactionTable({
onLoadSplitChildren,
onSaveSplit,
onDeleteSplit,
onRowContextMenu,
}: TransactionTableProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null);
@ -135,6 +137,7 @@ export default function TransactionTable({
{rows.map((row) => (
<Fragment key={row.id}>
<tr
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
className="hover:bg-[var(--muted)] transition-colors"
>
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>

View file

@ -46,7 +46,7 @@ interface ProfileContextValue {
error: string | null;
switchProfile: (id: string) => Promise<void>;
createProfile: (name: string, color: string, pin?: string) => Promise<void>;
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => Promise<void>;
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => Promise<void>;
deleteProfile: (id: string) => Promise<void>;
setPin: (id: string, pin: string | null) => Promise<void>;
connectActiveProfile: () => Promise<void>;
@ -151,7 +151,7 @@ export function ProfileProvider({ children }: { children: ReactNode }) {
}
}, [state.config]);
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => {
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => {
if (!state.config) return;
const newProfiles = state.config.profiles.map((p) =>

126
src/hooks/useAuth.ts Normal file
View file

@ -0,0 +1,126 @@
import { useCallback, useEffect, useReducer } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { listen } from "@tauri-apps/api/event";
import {
AccountInfo,
startOAuth,
getAccountInfo,
checkSubscriptionStatus,
logoutAccount,
} from "../services/authService";
type AuthStatus = "idle" | "loading" | "authenticated" | "unauthenticated" | "error";
interface AuthState {
status: AuthStatus;
account: AccountInfo | null;
error: string | null;
}
type AuthAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; account: AccountInfo | null }
| { type: "LOGIN_START" }
| { type: "LOGOUT" }
| { type: "ERROR"; error: string };
const initialState: AuthState = {
status: "idle",
account: null,
error: null,
};
function reducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return {
status: action.account ? "authenticated" : "unauthenticated",
account: action.account,
error: null,
};
case "LOGIN_START":
return { ...state, status: "loading", error: null };
case "LOGOUT":
return { status: "unauthenticated", account: null, error: null };
case "ERROR":
return { ...state, status: "error", error: action.error };
}
}
export function useAuth() {
const [state, dispatch] = useReducer(reducer, initialState);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
// checkSubscriptionStatus refreshes the token if last check > 24h,
// otherwise returns cached account info. Graceful on network errors.
const account = await checkSubscriptionStatus();
dispatch({ type: "LOAD_DONE", account });
} catch (e) {
// Fallback to cached account info if the check command itself fails
try {
const cached = await getAccountInfo();
dispatch({ type: "LOAD_DONE", account: cached });
} catch {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}
}, []);
const login = useCallback(async () => {
dispatch({ type: "LOGIN_START" });
try {
const url = await startOAuth();
await openUrl(url);
// The actual auth completion happens via the deep-link callback,
// which triggers handle_auth_callback on the Rust side.
// The UI should call refresh() after the callback completes.
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const logout = useCallback(async () => {
try {
await logoutAccount();
dispatch({ type: "LOGOUT" });
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Listen for deep-link auth callback events from the Rust backend
useEffect(() => {
const unlisten: Array<() => void> = [];
listen<AccountInfo>("auth-callback-success", (event) => {
dispatch({ type: "LOAD_DONE", account: event.payload });
}).then((fn) => unlisten.push(fn));
listen<string>("auth-callback-error", (event) => {
dispatch({ type: "ERROR", error: event.payload });
}).then((fn) => unlisten.push(fn));
return () => {
unlisten.forEach((fn) => fn());
};
}, []);
return { state, refresh, login, logout };
}

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { defaultCartesReferencePeriod } from "./useCartes";
describe("defaultCartesReferencePeriod", () => {
it("returns the month before the given date", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 3, 15))).toEqual({
year: 2026,
month: 3,
});
});
it("wraps around January to December of the previous year", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 0, 10))).toEqual({
year: 2025,
month: 12,
});
});
it("handles the last day of a month", () => {
expect(defaultCartesReferencePeriod(new Date(2026, 5, 30))).toEqual({
year: 2026,
month: 5,
});
});
});

103
src/hooks/useCartes.ts Normal file
View file

@ -0,0 +1,103 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CartesSnapshot } from "../shared/types";
import { getCartesSnapshot } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
interface State {
year: number;
month: number;
snapshot: CartesSnapshot | null;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SNAPSHOT"; payload: CartesSnapshot }
| { type: "SET_ERROR"; payload: string };
/**
* Default reference period for the Cartes report: the month preceding `today`.
* January wraps around to December of the previous year. Exported for tests.
*/
export function defaultCartesReferencePeriod(
today: Date = new Date(),
): { year: number; month: number } {
const y = today.getFullYear();
const m = today.getMonth() + 1;
if (m === 1) return { year: y - 1, month: 12 };
return { year: y, month: m - 1 };
}
const defaultRef = defaultCartesReferencePeriod();
const initialState: State = {
year: defaultRef.year,
month: defaultRef.month,
snapshot: null,
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_REFERENCE_PERIOD":
return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_SNAPSHOT":
return { ...state, snapshot: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useCartes() {
const { from, to, period, setPeriod, setCustomDates } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(async (year: number, month: number) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const snapshot = await getCartesSnapshot(year, month);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_SNAPSHOT", payload: snapshot });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
useEffect(() => {
fetch(state.year, state.month);
}, [fetch, state.year, state.month]);
// Keep the reference month in sync with the URL `to` date, so navigating
// via PeriodSelector works as expected.
useEffect(() => {
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
if (y !== state.year || m !== state.month) {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]);
const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []);
return {
...state,
setReferencePeriod,
from,
to,
period,
setPeriod,
setCustomDates,
};
}

View file

@ -0,0 +1,85 @@
import { useReducer, useEffect, useRef, useCallback } from "react";
import type { CategoryZoomData } from "../shared/types";
import { getCategoryZoom } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
interface State {
zoomedCategoryId: number | null;
rollupChildren: boolean;
data: CategoryZoomData | null;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_CATEGORY"; payload: number | null }
| { type: "TOGGLE_ROLLUP"; payload: boolean }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_DATA"; payload: CategoryZoomData }
| { type: "SET_ERROR"; payload: string };
const initialState: State = {
zoomedCategoryId: null,
rollupChildren: true,
data: null,
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CATEGORY":
return { ...state, zoomedCategoryId: action.payload, data: null };
case "TOGGLE_ROLLUP":
return { ...state, rollupChildren: action.payload };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_DATA":
return { ...state, data: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useCategoryZoom() {
const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(
async (categoryId: number | null, includeChildren: boolean, dateFrom: string, dateTo: string) => {
if (categoryId === null) return;
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const data = await getCategoryZoom(categoryId, dateFrom, dateTo, includeChildren);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: data });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
},
[],
);
useEffect(() => {
fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
const setCategory = useCallback((id: number | null) => {
dispatch({ type: "SET_CATEGORY", payload: id });
}, []);
const setRollupChildren = useCallback((flag: boolean) => {
dispatch({ type: "TOGGLE_ROLLUP", payload: flag });
}, []);
const refetch = useCallback(() => {
fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
return { ...state, setCategory, setRollupChildren, refetch, from, to };
}

View file

@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
describe("useCompare helpers", () => {
describe("previousMonth", () => {
it("goes back one month within the same year", () => {
expect(previousMonth(2026, 3)).toEqual({ year: 2026, month: 2 });
expect(previousMonth(2026, 12)).toEqual({ year: 2026, month: 11 });
});
it("wraps around January to December of previous year", () => {
expect(previousMonth(2026, 1)).toEqual({ year: 2025, month: 12 });
});
});
describe("defaultReferencePeriod", () => {
it("returns the month before the given date", () => {
expect(defaultReferencePeriod(new Date(2026, 3, 15))).toEqual({ year: 2026, month: 3 });
});
it("wraps around when today is in January", () => {
expect(defaultReferencePeriod(new Date(2026, 0, 10))).toEqual({ year: 2025, month: 12 });
});
it("handles the last day of a month", () => {
expect(defaultReferencePeriod(new Date(2026, 6, 31))).toEqual({ year: 2026, month: 6 });
});
});
describe("comparisonMeta", () => {
it("MoM returns the previous month", () => {
expect(comparisonMeta("mom", 2026, 3)).toEqual({ previousYear: 2026, previousMonth: 2 });
});
it("MoM wraps around January", () => {
expect(comparisonMeta("mom", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 12 });
});
it("YoY returns the same month in the previous year", () => {
expect(comparisonMeta("yoy", 2026, 3)).toEqual({ previousYear: 2025, previousMonth: 3 });
});
it("YoY for January stays on January of previous year", () => {
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
});
});
});

145
src/hooks/useCompare.ts Normal file
View file

@ -0,0 +1,145 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CategoryDelta } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
export type CompareMode = "actual" | "budget";
export type CompareSubMode = "mom" | "yoy";
interface State {
mode: CompareMode;
subMode: CompareSubMode;
year: number;
month: number;
rows: CategoryDelta[];
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_MODE"; payload: CompareMode }
| { type: "SET_SUB_MODE"; payload: CompareSubMode }
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ERROR"; payload: string };
/**
* Wrap-around helper: returns (year, month) shifted back by one month.
* Example: previousMonth(2026, 1) -> { year: 2025, month: 12 }.
*/
export function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
/**
* Default reference period for the Compare report: the month preceding `today`.
* Exported for unit tests.
*/
export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } {
return previousMonth(today.getFullYear(), today.getMonth() + 1);
}
/**
* Returns the comparison meta for a given subMode + reference period.
* - MoM: previous month vs current month
* - YoY: same month previous year vs current year
*/
export function comparisonMeta(
subMode: CompareSubMode,
year: number,
month: number,
): { previousYear: number; previousMonth: number } {
if (subMode === "mom") {
const prev = previousMonth(year, month);
return { previousYear: prev.year, previousMonth: prev.month };
}
return { previousYear: year - 1, previousMonth: month };
}
const defaultRef = defaultReferencePeriod();
const initialState: State = {
mode: "actual",
subMode: "mom",
year: defaultRef.year,
month: defaultRef.month,
rows: [],
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_MODE":
return { ...state, mode: action.payload };
case "SET_SUB_MODE":
return { ...state, subMode: action.payload };
case "SET_REFERENCE_PERIOD":
return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useCompare() {
const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const rows =
subMode === "mom"
? await getCompareMonthOverMonth(year, month)
: await getCompareYearOverYear(year);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
},
[],
);
useEffect(() => {
fetch(state.mode, state.subMode, state.year, state.month);
}, [fetch, state.mode, state.subMode, state.year, state.month]);
// When the URL period changes, align the reference month with `to`.
// The explicit dropdown remains the primary selector — this effect only
// keeps the two in sync when the user navigates via PeriodSelector.
useEffect(() => {
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
if (y !== state.year || m !== state.month) {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]);
const setMode = useCallback((m: CompareMode) => {
dispatch({ type: "SET_MODE", payload: m });
}, []);
const setSubMode = useCallback((s: CompareSubMode) => {
dispatch({ type: "SET_SUB_MODE", payload: s });
}, []);
const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []);
return { ...state, setMode, setSubMode, setReferencePeriod, from, to };
}

View file

@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import {
feedbackReducer,
initialFeedbackState,
type FeedbackState,
} from "./useFeedback";
describe("feedbackReducer", () => {
it("starts in idle with no error", () => {
expect(initialFeedbackState).toEqual({ status: "idle", errorCode: null });
});
it("transitions idle → sending on SEND_START", () => {
const next = feedbackReducer(initialFeedbackState, { type: "SEND_START" });
expect(next).toEqual({ status: "sending", errorCode: null });
});
it("clears a previous error code when re-sending", () => {
const prev: FeedbackState = { status: "error", errorCode: "rate_limit" };
const next = feedbackReducer(prev, { type: "SEND_START" });
expect(next.errorCode).toBeNull();
});
it("transitions sending → success", () => {
const prev: FeedbackState = { status: "sending", errorCode: null };
const next = feedbackReducer(prev, { type: "SEND_SUCCESS" });
expect(next).toEqual({ status: "success", errorCode: null });
});
it("transitions sending → error and records the code", () => {
const prev: FeedbackState = { status: "sending", errorCode: null };
const next = feedbackReducer(prev, {
type: "SEND_ERROR",
code: "network_error",
});
expect(next).toEqual({ status: "error", errorCode: "network_error" });
});
it("RESET returns to the initial state regardless of prior state", () => {
const prev: FeedbackState = { status: "error", errorCode: "invalid" };
const next = feedbackReducer(prev, { type: "RESET" });
expect(next).toEqual(initialFeedbackState);
});
});

68
src/hooks/useFeedback.ts Normal file
View file

@ -0,0 +1,68 @@
import { useCallback, useReducer } from "react";
import {
sendFeedback,
type FeedbackContext,
type FeedbackErrorCode,
isFeedbackErrorCode,
} from "../services/feedbackService";
export type FeedbackStatus = "idle" | "sending" | "success" | "error";
export interface FeedbackState {
status: FeedbackStatus;
errorCode: FeedbackErrorCode | null;
}
export type FeedbackAction =
| { type: "SEND_START" }
| { type: "SEND_SUCCESS" }
| { type: "SEND_ERROR"; code: FeedbackErrorCode }
| { type: "RESET" };
export const initialFeedbackState: FeedbackState = {
status: "idle",
errorCode: null,
};
export function feedbackReducer(
_state: FeedbackState,
action: FeedbackAction,
): FeedbackState {
switch (action.type) {
case "SEND_START":
return { status: "sending", errorCode: null };
case "SEND_SUCCESS":
return { status: "success", errorCode: null };
case "SEND_ERROR":
return { status: "error", errorCode: action.code };
case "RESET":
return initialFeedbackState;
}
}
export interface SubmitArgs {
content: string;
userId?: string | null;
context?: FeedbackContext;
}
export function useFeedback() {
const [state, dispatch] = useReducer(feedbackReducer, initialFeedbackState);
const submit = useCallback(async (args: SubmitArgs) => {
dispatch({ type: "SEND_START" });
try {
await sendFeedback(args);
dispatch({ type: "SEND_SUCCESS" });
} catch (e) {
const code: FeedbackErrorCode = isFeedbackErrorCode(e)
? e
: "network_error";
dispatch({ type: "SEND_ERROR", code });
}
}, []);
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
return { state, submit, reset };
}

View file

@ -0,0 +1,68 @@
import { useReducer, useEffect, useRef, useCallback } from "react";
import type { HighlightsData } from "../shared/types";
import { getHighlights } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
interface State {
data: HighlightsData | null;
windowDays: 30 | 60 | 90;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_DATA"; payload: HighlightsData }
| { type: "SET_ERROR"; payload: string }
| { type: "SET_WINDOW_DAYS"; payload: 30 | 60 | 90 };
const initialState: State = {
data: null,
windowDays: 30,
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_DATA":
return { ...state, data: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_WINDOW_DAYS":
return { ...state, windowDays: action.payload };
default:
return state;
}
}
export function useHighlights() {
const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(async (windowDays: 30 | 60 | 90, referenceDate: string) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const data = await getHighlights(windowDays, referenceDate);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: data });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
useEffect(() => {
fetch(state.windowDays, to);
}, [fetch, state.windowDays, to]);
const setWindowDays = useCallback((d: 30 | 60 | 90) => {
dispatch({ type: "SET_WINDOW_DAYS", payload: d });
}, []);
return { ...state, setWindowDays, from, to };
}

View file

@ -1,213 +0,0 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
ReportTab,
DashboardPeriod,
MonthlyTrendItem,
CategoryBreakdownItem,
CategoryOverTimeData,
BudgetVsActualRow,
PivotConfig,
PivotResult,
} from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
interface ReportsState {
tab: ReportTab;
period: DashboardPeriod;
customDateFrom: string;
customDateTo: string;
sourceId: number | null;
categoryType: CategoryTypeFilter;
monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetYear: number;
budgetMonth: number;
budgetVsActual: BudgetVsActualRow[];
pivotConfig: PivotConfig;
pivotResult: PivotResult;
isLoading: boolean;
error: string | null;
}
type ReportsAction =
| { type: "SET_TAB"; payload: ReportTab }
| { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
| { type: "SET_SOURCE_ID"; payload: number | null }
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const initialState: ReportsState = {
tab: "trends",
period: "6months",
customDateFrom: monthStartStr,
customDateTo: todayStr,
sourceId: null,
categoryType: "expense",
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
budgetVsActual: [],
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
isLoading: false,
error: null,
};
function reducer(state: ReportsState, action: ReportsAction): ReportsState {
switch (action.type) {
case "SET_TAB":
return { ...state, tab: action.payload };
case "SET_PERIOD":
return { ...state, period: action.payload };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_MONTHLY_TRENDS":
return { ...state, monthlyTrends: action.payload, isLoading: false };
case "SET_CATEGORY_SPENDING":
return { ...state, categorySpending: action.payload, isLoading: false };
case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_BUDGET_VS_ACTUAL":
return { ...state, budgetVsActual: action.payload, isLoading: false };
case "SET_PIVOT_CONFIG":
return { ...state, pivotConfig: action.payload };
case "SET_PIVOT_RESULT":
return { ...state, pivotResult: action.payload, isLoading: false };
case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
case "SET_SOURCE_ID":
return { ...state, sourceId: action.payload };
case "SET_CATEGORY_TYPE":
return { ...state, categoryType: action.payload };
default:
return state;
}
}
export function useReports() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetchData = useCallback(async (
tab: ReportTab,
period: DashboardPeriod,
budgetYear: number,
budgetMonth: number,
customFrom?: string,
customTo?: string,
pivotCfg?: PivotConfig,
srcId?: number | null,
catType?: CategoryTypeFilter,
) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
switch (tab) {
case "trends": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
break;
}
case "byCategory": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
break;
}
case "overTime": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
break;
}
case "budgetVsActual": {
const data = await getBudgetVsActualData(budgetYear, budgetMonth);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
break;
}
case "dynamic": {
if (!pivotCfg || (pivotCfg.rows.length === 0 && pivotCfg.columns.length === 0) || pivotCfg.values.length === 0) {
dispatch({ type: "SET_PIVOT_RESULT", payload: { rows: [], columnValues: [], dimensionLabels: {} } });
break;
}
const data = await getDynamicReportData(pivotCfg);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_PIVOT_RESULT", payload: data });
break;
}
}
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType, fetchData]);
const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab });
}, []);
const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period });
}, []);
const setBudgetMonth = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
}, []);
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
const setPivotConfig = useCallback((config: PivotConfig) => {
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
}, []);
const setSourceId = useCallback((id: number | null) => {
dispatch({ type: "SET_SOURCE_ID", payload: id });
}, []);
const setCategoryType = useCallback((catType: CategoryTypeFilter) => {
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
}, []);
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType };
}

View file

@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { resolveReportsPeriod } from "./useReportsPeriod";
describe("resolveReportsPeriod", () => {
const fixedToday = new Date("2026-04-14T12:00:00Z");
it("defaults to current civil year when no URL params are set", () => {
const result = resolveReportsPeriod(null, null, null, fixedToday);
expect(result.from).toBe("2026-01-01");
expect(result.to).toBe("2026-12-31");
expect(result.period).toBe("custom");
});
it("restores state from bookmarked from/to params", () => {
const result = resolveReportsPeriod("2025-03-01", "2025-06-30", null, fixedToday);
expect(result.from).toBe("2025-03-01");
expect(result.to).toBe("2025-06-30");
expect(result.period).toBe("custom");
});
it("keeps period=yearly alongside explicit from/to", () => {
const result = resolveReportsPeriod("2024-01-01", "2024-12-31", "year", fixedToday);
expect(result.period).toBe("year");
});
it("ignores malformed dates and falls back to the civil year", () => {
const result = resolveReportsPeriod("not-a-date", "also-not", null, fixedToday);
expect(result.from).toBe("2026-01-01");
expect(result.to).toBe("2026-12-31");
expect(result.period).toBe("custom");
});
it("resolves preset period values without from/to", () => {
const result = resolveReportsPeriod(null, null, "6months", fixedToday);
expect(result.period).toBe("6months");
expect(result.from).toBeTruthy();
expect(result.to).toBeTruthy();
});
it("rejects an invalid period string and falls back to civil year custom", () => {
const result = resolveReportsPeriod(null, null, "bogus", fixedToday);
expect(result.period).toBe("custom");
expect(result.from).toBe("2026-01-01");
});
it("treats `all` as a preset with empty range (service handles the clauses)", () => {
const result = resolveReportsPeriod(null, null, "all", fixedToday);
expect(result.period).toBe("all");
// Fallback civil year when computeDateRange returns empty
expect(result.from).toBe("2026-01-01");
expect(result.to).toBe("2026-12-31");
});
});

View file

@ -0,0 +1,119 @@
import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type { DashboardPeriod } from "../shared/types";
import { computeDateRange } from "../utils/dateRange";
const VALID_PERIODS: readonly DashboardPeriod[] = [
"month",
"3months",
"6months",
"year",
"12months",
"all",
"custom",
];
function isValidPeriod(p: string | null): p is DashboardPeriod {
return p !== null && (VALID_PERIODS as readonly string[]).includes(p);
}
function isValidIsoDate(s: string | null): s is string {
return !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
}
function currentYearRange(today: Date = new Date()): { from: string; to: string } {
const year = today.getFullYear();
return { from: `${year}-01-01`, to: `${year}-12-31` };
}
/**
* Pure resolver used by the hook and unit tests. Exposed to keep the core
* logic hookless and testable without rendering a router.
*/
export function resolveReportsPeriod(
rawFrom: string | null,
rawTo: string | null,
rawPeriod: string | null,
today: Date = new Date(),
): { from: string; to: string; period: DashboardPeriod } {
if (isValidIsoDate(rawFrom) && isValidIsoDate(rawTo)) {
const p = isValidPeriod(rawPeriod) ? rawPeriod : "custom";
return { from: rawFrom, to: rawTo, period: p };
}
if (isValidPeriod(rawPeriod) && rawPeriod !== "custom") {
const range = computeDateRange(rawPeriod);
const { from: defaultFrom, to: defaultTo } = currentYearRange(today);
return {
from: range.dateFrom ?? defaultFrom,
to: range.dateTo ?? defaultTo,
period: rawPeriod,
};
}
const { from, to } = currentYearRange(today);
return { from, to, period: "custom" };
}
export interface UseReportsPeriodResult {
from: string;
to: string;
period: DashboardPeriod;
setPeriod: (period: DashboardPeriod) => void;
setCustomDates: (from: string, to: string) => void;
}
/**
* Reads/writes the active reporting period via the URL query string so it is
* bookmarkable and shared across the four report sub-routes.
*
* Defaults to the current civil year (Jan 1 Dec 31).
*/
export function useReportsPeriod(): UseReportsPeriodResult {
const [searchParams, setSearchParams] = useSearchParams();
const rawPeriod = searchParams.get("period");
const rawFrom = searchParams.get("from");
const rawTo = searchParams.get("to");
const { from, to, period } = useMemo(
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
[rawPeriod, rawFrom, rawTo],
);
const setPeriod = useCallback(
(next: DashboardPeriod) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
if (next === "custom") {
params.set("period", "custom");
} else {
params.set("period", next);
params.delete("from");
params.delete("to");
}
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
const setCustomDates = useCallback(
(nextFrom: string, nextTo: string) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set("period", "custom");
params.set("from", nextFrom);
params.set("to", nextTo);
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
return { from, to, period, setPeriod, setCustomDates };
}

81
src/hooks/useTrends.ts Normal file
View file

@ -0,0 +1,81 @@
import { useReducer, useEffect, useRef, useCallback } from "react";
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
export type TrendsSubView = "global" | "byCategory";
interface State {
subView: TrendsSubView;
monthlyTrends: MonthlyTrendItem[];
categoryOverTime: CategoryOverTimeData;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_SUBVIEW"; payload: TrendsSubView }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_ERROR"; payload: string };
const initialState: State = {
subView: "global",
monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_SUBVIEW":
return { ...state, subView: action.payload };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_TRENDS":
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useTrends() {
const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
if (subView === "global") {
const data = await getMonthlyTrends(dateFrom, dateTo);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
}
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
useEffect(() => {
fetch(state.subView, from, to);
}, [fetch, state.subView, from, to]);
const setSubView = useCallback((sv: TrendsSubView) => {
dispatch({ type: "SET_SUBVIEW", payload: sv });
}, []);
return { ...state, setSubView, from, to };
}

View file

@ -1,6 +1,7 @@
import { useReducer, useCallback, useRef } from "react";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { invoke } from "@tauri-apps/api/core";
type UpdateStatus =
| "idle"
@ -10,6 +11,7 @@ type UpdateStatus =
| "downloading"
| "readyToInstall"
| "installing"
| "notEntitled"
| "error";
interface UpdaterState {
@ -29,6 +31,7 @@ type UpdaterAction =
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
| { type: "READY_TO_INSTALL" }
| { type: "INSTALLING" }
| { type: "NOT_ENTITLED" }
| { type: "ERROR"; error: string };
const initialState: UpdaterState = {
@ -56,6 +59,8 @@ function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
return { ...state, status: "readyToInstall", error: null };
case "INSTALLING":
return { ...state, status: "installing", error: null };
case "NOT_ENTITLED":
return { ...state, status: "notEntitled", error: null };
case "ERROR":
return { ...state, status: "error", error: action.error };
}
@ -68,6 +73,16 @@ export function useUpdater() {
const checkForUpdate = useCallback(async () => {
dispatch({ type: "CHECK_START" });
try {
// Auto-updates are gated behind the entitlements module (Issue #46/#48).
// The check is centralized server-side via `check_entitlement` so the
// tier→feature mapping lives in one place.
const allowed = await invoke<boolean>("check_entitlement", {
feature: "auto-update",
});
if (!allowed) {
dispatch({ type: "NOT_ENTITLED" });
return;
}
const update = await check();
if (update) {
updateRef.current = update;

View file

@ -358,7 +358,10 @@
"period": "Period",
"byCategory": "Expenses by Category",
"overTime": "Category Over Time",
"trends": "Monthly Trends",
"trends": {
"subviewGlobal": "Global flow",
"subviewByCategory": "By category"
},
"budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom",
@ -381,33 +384,93 @@
"noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for"
},
"dynamic": "Dynamic Report",
"export": "Export",
"pivot": {
"availableFields": "Available Fields",
"rows": "Rows",
"columns": "Columns",
"filters": "Filters",
"values": "Values",
"addTo": "Add to...",
"year": "Year",
"month": "Month",
"categoryType": "Type",
"level1": "Category (Level 1)",
"level2": "Category (Level 2)",
"level3": "Category (Level 3)",
"periodic": "Periodic Amount",
"ytd": "Year-to-Date (YTD)",
"subtotal": "Subtotal",
"total": "Total",
"viewTable": "Table",
"viewChart": "Chart",
"viewBoth": "Both",
"noConfig": "Add fields to generate the report",
"noData": "No data for this configuration",
"fullscreen": "Full screen",
"exitFullscreen": "Exit full screen",
"rightClickExclude": "Right-click to exclude"
"month": "Month",
"viewMode": {
"chart": "Chart",
"table": "Table"
},
"hub": {
"title": "Reports",
"explore": "Explore",
"highlights": "Highlights",
"highlightsDescription": "What moved this month",
"trends": "Trends",
"trendsDescription": "Where you're heading over 12 months",
"compare": "Compare",
"compareDescription": "Compare a reference month against previous month, previous year, or budget",
"categoryZoom": "Category Analysis",
"categoryZoomDescription": "Zoom in on a single category",
"cartes": "Cards",
"cartesDescription": "KPI dashboard with sparklines, top movers, budget adherence, and seasonality"
},
"compare": {
"modeActual": "Actual vs actual",
"modeBudget": "Actual vs budget",
"subModeMoM": "Previous month",
"subModeYoY": "Previous year",
"subModeAria": "Comparison period",
"referenceMonth": "Reference month"
},
"cartes": {
"kpiSectionAria": "Key indicators for the reference month",
"income": "Income",
"expenses": "Expenses",
"net": "Net balance",
"savingsRate": "Savings rate",
"deltaMoMLabel": "vs last month",
"deltaYoYLabel": "vs last year",
"flowChartTitle": "Income vs expenses — last 12 months",
"topMoversUp": "Biggest increases",
"topMoversDown": "Biggest decreases",
"budgetAdherenceTitle": "Budget adherence",
"budgetAdherenceSubtitle": "{{score}} of budgeted categories on target",
"budgetAdherenceEmpty": "No budgeted categories this month",
"budgetAdherenceWorst": "Worst overruns",
"seasonalityTitle": "Seasonality",
"seasonalityEmpty": "Not enough history for this month",
"seasonalityAverage": "Average",
"seasonalityDeviation": "{{pct}} vs average"
},
"category": {
"selectCategory": "Select a category",
"includeSubcategories": "Include subcategories",
"directOnly": "Direct only",
"breakdown": "Total",
"evolution": "Evolution",
"transactions": "Transactions"
},
"keyword": {
"addFromTransaction": "Add as keyword",
"dialogTitle": "New keyword",
"willMatch": "Will also match",
"nMatches_one": "{{count}} transaction matched",
"nMatches_other": "{{count}} transactions matched",
"applyAndRecategorize": "Apply and recategorize",
"applyToHidden": "Also apply to {{count}} non-displayed transactions",
"tooShort": "Minimum {{min}} characters",
"tooLong": "Maximum {{max}} characters",
"alreadyExists": "This keyword already exists for another category. Reassign?"
},
"highlights": {
"balances": "Balances",
"netBalanceCurrent": "This month",
"netBalanceYtd": "Year to date",
"topMovers": "Top movers",
"topTransactions": "Top recent transactions",
"category": "Category",
"previousAmount": "Previous",
"currentAmount": "Current",
"variationAbs": "Delta ($)",
"variationPct": "Delta (%)",
"vsLastMonth": "vs. last month",
"windowDays30": "30 days",
"windowDays60": "60 days",
"windowDays90": "90 days"
},
"empty": {
"noData": "No data for this period",
"importCta": "Import a statement"
},
"help": {
"title": "How to use Reports",
@ -415,8 +478,7 @@
"Switch between Trends, By Category, and Over Time views using the tabs",
"Use the period selector to adjust the time range for all charts",
"Monthly Trends shows your income and expenses over time",
"Category Over Time tracks how spending in each category evolves",
"Dynamic Report lets you build custom pivot tables by assigning dimensions to rows, columns, and filters"
"Category Over Time tracks how spending in each category evolves"
]
}
},
@ -436,7 +498,8 @@
"installing": "Installing...",
"error": "Update failed",
"retryButton": "Retry",
"releaseNotes": "What's New"
"releaseNotes": "What's New",
"notEntitled": "Automatic updates are available with the Base edition. Activate a license to enable them."
},
"dataManagement": {
"title": "Data Management",
@ -738,32 +801,29 @@
},
"reports": {
"title": "Reports",
"overview": "Visualize your financial data with interactive charts and compare your budget plan against actual spending.",
"overview": "A hub with a live highlights panel plus four dedicated sub-reports (Highlights, Trends, Compare, Category Zoom). Every page shares a bookmarkable period via the URL query string.",
"features": [
"Monthly Trends: income vs. expenses over time (bar chart)",
"Expenses by Category: spending breakdown (pie chart)",
"Category Over Time: track how each category evolves (line chart)",
"Budget vs Actual: monthly and year-to-date comparison table",
"Dynamic Report: customizable pivot table",
"Hub: compact highlights panel + 4 navigation cards",
"Highlights: current month and YTD balances with sparklines, top movers vs. last month, top recent transactions (30/60/90 day window)",
"Trends: global flow (income vs. expenses) and by-category evolution with a chart/table toggle",
"Compare: Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget",
"Category Zoom: single-category drill-down with donut, monthly evolution, and filterable transaction table; auto-rollup of subcategories",
"Contextual keyword editing: right-click a transaction row to add its description as a keyword with a live preview of the matches",
"SVG patterns (lines, dots, crosshatch) to distinguish categories",
"Context menu (right-click) to hide a category or view its transactions",
"Transaction detail by category with sortable columns (date, description, amount)",
"Toggle to show or hide amounts in transaction detail"
"View mode preference (chart vs. table) persisted per report section"
],
"steps": [
"Use the tabs to switch between Trends, By Category, Over Time, and Budget vs Actual views",
"Adjust the time period using the period selector",
"Right-click a category in any chart to hide it or view its transaction details",
"Hidden categories appear as dismissible chips above the chart — click them to show again",
"In Budget vs Actual, toggle between Monthly and Year-to-Date views",
"In the category detail, click a column header to sort transactions",
"Use the eye icon in the detail view to show or hide the amounts column"
"Open /reports to see the highlights panel and four navigation cards",
"Adjust the period with the period selector — it is mirrored in the URL and shared with every sub-report",
"Click a card or a sub-route link to open the corresponding report",
"Toggle chart vs. table on any sub-report — your choice is remembered",
"Right-click any transaction row in the category zoom, highlights list, or transactions page to add a keyword",
"In the keyword dialog, review the preview of matching transactions and confirm to apply"
],
"tips": [
"Hidden categories are remembered while you stay on the page — click Show All to reset",
"The period selector applies to all chart tabs simultaneously",
"Budget vs Actual shows dollar and percentage variance for each category",
"SVG patterns help colorblind users distinguish categories in charts"
"Copy the URL to share a specific period + report state",
"Keywords must be 264 characters long",
"The Category Zoom is protected against malformed category trees: a parent_id cycle cannot freeze the app"
]
},
"settings": {
@ -776,7 +836,8 @@
"Application logs viewable with level filters, copy, and clear",
"Data export (transactions, categories, or both) in JSON or CSV format",
"Data import from a previously exported file",
"Optional AES-256-GCM encryption for exported files"
"Optional AES-256-GCM encryption for exported files",
"Optional feedback submission to feedback.lacompagniemaximus.com (explicit exception to the 100% local operation — prompts for consent)"
],
"steps": [
"Click User Guide to access the full documentation",
@ -784,7 +845,8 @@
"View the Logs section to see application logs — filter by level (All, Error, Warn, Info), copy or clear",
"Use the Data Management section to export or import your data",
"When exporting, choose what to include and optionally set a password for encryption",
"When importing, select a previously exported file — encrypted files will prompt for the password"
"When importing, select a previously exported file — encrypted files will prompt for the password",
"Click Send feedback in the Logs section to share a suggestion, comment, or issue — the identify and context/logs checkboxes are unchecked by default"
],
"tips": [
"Updates only replace the app binary — your database is never modified",
@ -792,7 +854,8 @@
"Export regularly to keep a backup of your data",
"The user guide can be printed or exported to PDF via the Print button",
"Logs persist for the session — they survive a page refresh",
"If you encounter an issue, copy the logs and attach them to your report"
"If you encounter an issue, copy the logs and attach them to your report",
"Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
]
}
},
@ -827,6 +890,7 @@
"checkUpdate": "Check for updates",
"updateAvailable": "Update available: v{{version}}",
"upToDate": "The application is up to date",
"updateNotEntitled": "Automatic updates are available with the Base edition.",
"contactUs": "Contact us",
"contactEmail": "Send an email to",
"reportIssue": "Report an issue"
@ -845,7 +909,9 @@
"language": "Language",
"total": "Total",
"darkMode": "Dark mode",
"lightMode": "Light mode"
"lightMode": "Light mode",
"close": "Close",
"underConstruction": "Under construction"
},
"license": {
"title": "License",
@ -859,6 +925,62 @@
"free": "Free",
"base": "Base",
"premium": "Premium"
},
"removeLicense": "Remove license",
"machines": {
"title": "Machines",
"count": "{{count}}/{{limit}} machines activated",
"activating": "Activating...",
"activated": "Activated",
"notActivated": "Not activated",
"deactivate": "Deactivate",
"deactivating": "Deactivating...",
"activateError": "Activation failed",
"thisMachine": "This machine",
"noMachines": "No machines activated"
}
},
"account": {
"title": "Maximus Account",
"optional": "Optional",
"description": "Sign in to access Premium features (web version, sync). The account is only required for Premium features.",
"signIn": "Sign in",
"signOut": "Sign out",
"connected": "Connected",
"tokenStore": {
"fallback": {
"title": "Tokens stored in plaintext fallback",
"description": "Your authentication tokens are currently stored in a local file protected by filesystem permissions. For stronger protection via the OS keychain, make sure a keyring service is running (GNOME Keyring, KWallet, or equivalent)."
}
}
},
"feedback": {
"button": "Send feedback",
"logsHeading": "Recent logs:",
"dialog": {
"title": "Your feedback",
"placeholder": "Describe your suggestion, comment, or issue...",
"submit": "Send",
"sending": "Sending...",
"cancel": "Cancel"
},
"checkbox": {
"context": "Include navigation context (page, theme, viewport, version)",
"logs": "Include recent error logs",
"identify": "Identify me with my Maximus account"
},
"toast": {
"success": "Thank you for your feedback",
"error": {
"429": "Too many feedbacks sent recently. Try again later.",
"400": "Invalid feedback. Check the content.",
"generic": "Error while sending. Try again later."
}
},
"consent": {
"title": "Feedback submission",
"body": "Your feedback will be sent to feedback.lacompagniemaximus.com so we can improve the app. This requires an internet connection and is an exception to the app's 100% local operation.",
"accept": "I agree"
}
}
}

View file

@ -358,8 +358,11 @@
"period": "Période",
"byCategory": "Dépenses par catégorie",
"overTime": "Catégories dans le temps",
"trends": "Tendances mensuelles",
"budgetVsActual": "Budget vs R\u00e9el",
"trends": {
"subviewGlobal": "Flux global",
"subviewByCategory": "Par catégorie"
},
"budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas",
"detail": {
@ -376,38 +379,98 @@
"bva": {
"monthly": "Mensuel",
"ytd": "Cumul annuel",
"dollarVar": "$ \u00c9cart",
"pctVar": "% \u00c9cart",
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
"dollarVar": "$ Écart",
"pctVar": "% Écart",
"noData": "Aucune donnée de budget ou de transaction pour cette période.",
"titlePrefix": "Budget vs Réel pour le mois de"
},
"dynamic": "Rapport dynamique",
"export": "Exporter",
"pivot": {
"availableFields": "Champs disponibles",
"rows": "Lignes",
"columns": "Colonnes",
"filters": "Filtres",
"values": "Valeurs",
"addTo": "Ajouter à...",
"year": "Année",
"month": "Mois",
"categoryType": "Type",
"level1": "Catégorie (Niveau 1)",
"level2": "Catégorie (Niveau 2)",
"level3": "Catégorie (Niveau 3)",
"periodic": "Montant périodique",
"ytd": "Cumul annuel (YTD)",
"subtotal": "Sous-total",
"total": "Total",
"viewTable": "Tableau",
"viewChart": "Graphique",
"viewBoth": "Les deux",
"noConfig": "Ajoutez des champs pour générer le rapport",
"noData": "Aucune donnée pour cette configuration",
"fullscreen": "Plein écran",
"exitFullscreen": "Quitter plein écran",
"rightClickExclude": "Clic-droit pour exclure"
"month": "Mois",
"viewMode": {
"chart": "Graphique",
"table": "Tableau"
},
"hub": {
"title": "Rapports",
"explore": "Explorer",
"highlights": "Faits saillants",
"highlightsDescription": "Ce qui a bougé ce mois-ci",
"trends": "Tendances",
"trendsDescription": "Où vous allez sur 12 mois",
"compare": "Comparables",
"compareDescription": "Comparer un mois de référence au précédent, à l'année passée ou au budget",
"categoryZoom": "Analyse par catégorie",
"categoryZoomDescription": "Zoom sur une catégorie",
"cartes": "Cartes",
"cartesDescription": "Tableau de bord KPI, sparklines, top mouvements, budget et saisonnalité"
},
"compare": {
"modeActual": "Réel vs réel",
"modeBudget": "Réel vs budget",
"subModeMoM": "Mois précédent",
"subModeYoY": "Année précédente",
"subModeAria": "Période de comparaison",
"referenceMonth": "Mois de référence"
},
"cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence",
"income": "Revenus",
"expenses": "Dépenses",
"net": "Solde net",
"savingsRate": "Taux d'épargne",
"deltaMoMLabel": "vs mois précédent",
"deltaYoYLabel": "vs l'an dernier",
"flowChartTitle": "Revenus vs dépenses — 12 derniers mois",
"topMoversUp": "Catégories en hausse",
"topMoversDown": "Catégories en baisse",
"budgetAdherenceTitle": "Respect du budget",
"budgetAdherenceSubtitle": "{{score}} des catégories avec budget sont dans la cible",
"budgetAdherenceEmpty": "Aucune catégorie avec budget ce mois-ci",
"budgetAdherenceWorst": "Pires dépassements",
"seasonalityTitle": "Saisonnalité",
"seasonalityEmpty": "Pas assez d'historique pour ce mois",
"seasonalityAverage": "Moyenne",
"seasonalityDeviation": "{{pct}} par rapport à la moyenne"
},
"category": {
"selectCategory": "Choisir une catégorie",
"includeSubcategories": "Inclure les sous-catégories",
"directOnly": "Directe seulement",
"breakdown": "Total",
"evolution": "Évolution",
"transactions": "Transactions"
},
"keyword": {
"addFromTransaction": "Ajouter comme mot-clé",
"dialogTitle": "Nouveau mot-clé",
"willMatch": "Matchera aussi",
"nMatches_one": "{{count}} transaction matchée",
"nMatches_other": "{{count}} transactions matchées",
"applyAndRecategorize": "Appliquer et recatégoriser",
"applyToHidden": "Appliquer aussi aux {{count}} transactions non affichées",
"tooShort": "Minimum {{min}} caractères",
"tooLong": "Maximum {{max}} caractères",
"alreadyExists": "Ce mot-clé existe déjà pour une autre catégorie. Remplacer ?"
},
"highlights": {
"balances": "Soldes",
"netBalanceCurrent": "Ce mois-ci",
"netBalanceYtd": "Cumul annuel",
"topMovers": "Top mouvements",
"topTransactions": "Plus grosses transactions récentes",
"category": "Catégorie",
"previousAmount": "Précédent",
"currentAmount": "Courant",
"variationAbs": "Écart ($)",
"variationPct": "Écart (%)",
"vsLastMonth": "vs mois précédent",
"windowDays30": "30 jours",
"windowDays60": "60 jours",
"windowDays90": "90 jours"
},
"empty": {
"noData": "Aucune donnée pour cette période",
"importCta": "Importer un relevé"
},
"help": {
"title": "Comment utiliser les Rapports",
@ -415,8 +478,7 @@
"Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets",
"Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques",
"Les tendances mensuelles montrent vos revenus et dépenses au fil du temps",
"Catégories dans le temps suit l'évolution des dépenses par catégorie",
"Le Rapport dynamique permet de créer des tableaux croisés personnalisés en assignant des dimensions aux lignes, colonnes et filtres"
"Catégories dans le temps suit l'évolution des dépenses par catégorie"
]
}
},
@ -436,7 +498,8 @@
"installing": "Installation en cours...",
"error": "Erreur lors de la mise à jour",
"retryButton": "Réessayer",
"releaseNotes": "Nouveautés"
"releaseNotes": "Nouveautés",
"notEntitled": "Les mises à jour automatiques sont disponibles avec l'édition Base. Activez une licence pour les utiliser."
},
"dataManagement": {
"title": "Gestion des données",
@ -738,32 +801,29 @@
},
"reports": {
"title": "Rapports",
"overview": "Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.",
"overview": "Un hub qui affiche un panneau de faits saillants en direct plus quatre sous-rapports dédiés (Faits saillants, Tendances, Comparables, Zoom catégorie). Chaque page partage une période bookmarkable via la query string de l'URL.",
"features": [
"Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)",
"Dépenses par catégorie : répartition des dépenses (graphique circulaire)",
"Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)",
"Budget vs Réel : tableau comparatif mensuel et cumul annuel",
"Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable",
"Hub : panneau de faits saillants condensé + 4 cartes de navigation",
"Faits saillants : soldes mois courant et cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes (fenêtre 30/60/90 jours)",
"Tendances : flux global (revenus vs dépenses) et évolution par catégorie avec toggle graphique/tableau",
"Comparables : Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget",
"Zoom catégorie : analyse d'une seule catégorie avec donut, évolution mensuelle et tableau de transactions filtrable ; rollup automatique des sous-catégories",
"Édition contextuelle des mots-clés : clic droit sur une ligne de transaction pour ajouter sa description comme mot-clé avec prévisualisation en direct des matches",
"Motifs SVG (lignes, points, hachures) pour distinguer les catégories",
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions",
"Détail des transactions par catégorie avec tri par colonne (date, description, montant)",
"Toggle pour afficher ou masquer les montants dans le détail des transactions"
"Préférence chart/table mémorisée par section de rapport"
],
"steps": [
"Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel",
"Ajustez la période avec le sélecteur de période",
"Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions",
"Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher",
"Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel",
"Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions",
"Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants"
"Ouvrez /reports pour voir le panneau de faits saillants et les quatre cartes de navigation",
"Ajustez la période avec le sélecteur — elle est reflétée dans l'URL et partagée avec tous les sous-rapports",
"Cliquez sur une carte ou un lien pour ouvrir le sous-rapport correspondant",
"Basculez graphique/tableau sur n'importe quel sous-rapport — votre choix est mémorisé",
"Cliquez droit sur une ligne de transaction dans le zoom catégorie, la liste des faits saillants, ou la page transactions pour ajouter un mot-clé",
"Dans le dialog de mot-clé, passez en revue la prévisualisation des transactions qui matchent et confirmez pour appliquer"
],
"tips": [
"Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser",
"Le sélecteur de période s'applique à tous les onglets de graphiques simultanément",
"Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie",
"Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques"
"Copiez l'URL pour partager une période et un rapport spécifiques",
"Les mots-clés doivent faire entre 2 et 64 caractères",
"Le Zoom catégorie est protégé contre les arborescences malformées : un cycle parent_id ne peut pas figer l'app"
]
},
"settings": {
@ -776,7 +836,8 @@
"Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement",
"Export des données (transactions, catégories, ou les deux) en format JSON ou CSV",
"Import des données depuis un fichier exporté précédemment",
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés"
"Chiffrement AES-256-GCM optionnel pour les fichiers exportés",
"Envoi de feedback optionnel vers feedback.lacompagniemaximus.com (exception explicite au fonctionnement 100% local — déclenche une demande de consentement)"
],
"steps": [
"Cliquez sur Guide d'utilisation pour accéder à la documentation complète",
@ -784,7 +845,8 @@
"Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez",
"Utilisez la section Gestion des données pour exporter ou importer vos données",
"Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement",
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe"
"Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe",
"Cliquez sur Envoyer un feedback dans la section Journaux pour partager une suggestion, un commentaire ou un problème — la case d'identification et d'envoi du contexte/logs sont décochées par défaut"
],
"tips": [
"Les mises à jour ne remplacent que le programme — votre base de données n'est jamais modifiée",
@ -792,7 +854,8 @@
"Exportez régulièrement pour garder une sauvegarde de vos données",
"Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer",
"Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page",
"En cas de problème, copiez les journaux et joignez-les à votre signalement"
"En cas de problème, copiez les journaux et joignez-les à votre signalement",
"Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
]
}
},
@ -827,6 +890,7 @@
"checkUpdate": "Vérifier les mises à jour",
"updateAvailable": "Mise à jour disponible : v{{version}}",
"upToDate": "L'application est à jour",
"updateNotEntitled": "Les mises à jour automatiques sont disponibles avec l'édition Base.",
"contactUs": "Nous contacter",
"contactEmail": "Envoyez un email à",
"reportIssue": "Signaler un problème"
@ -845,7 +909,9 @@
"language": "Langue",
"total": "Total",
"darkMode": "Mode sombre",
"lightMode": "Mode clair"
"lightMode": "Mode clair",
"close": "Fermer",
"underConstruction": "En construction"
},
"license": {
"title": "Licence",
@ -859,6 +925,62 @@
"free": "Gratuite",
"base": "Base",
"premium": "Premium"
},
"removeLicense": "Supprimer la licence",
"machines": {
"title": "Machines",
"count": "{{count}}/{{limit}} machines activées",
"activating": "Activation...",
"activated": "Activée",
"notActivated": "Non activée",
"deactivate": "Désactiver",
"deactivating": "Désactivation...",
"activateError": "Échec de l'activation",
"thisMachine": "Cette machine",
"noMachines": "Aucune machine activée"
}
},
"account": {
"title": "Compte Maximus",
"optional": "Optionnel",
"description": "Connectez-vous pour accéder aux fonctionnalités Premium (version web, synchronisation). Le compte est requis uniquement pour les fonctionnalités Premium.",
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"connected": "Connecté",
"tokenStore": {
"fallback": {
"title": "Stockage des tokens en clair",
"description": "Vos jetons d'authentification sont stockés dans un fichier local protégé par les permissions du système. Pour une protection renforcée via le trousseau du système d'exploitation, vérifiez que le service de trousseau est disponible (GNOME Keyring, KWallet, ou équivalent)."
}
}
},
"feedback": {
"button": "Envoyer un feedback",
"logsHeading": "Logs récents :",
"dialog": {
"title": "Votre avis",
"placeholder": "Décrivez votre suggestion, commentaire ou problème...",
"submit": "Envoyer",
"sending": "Envoi...",
"cancel": "Annuler"
},
"checkbox": {
"context": "Inclure le contexte de navigation (page, thème, écran, version)",
"logs": "Inclure les derniers logs d'erreur",
"identify": "M'identifier avec mon compte Maximus"
},
"toast": {
"success": "Merci pour votre feedback",
"error": {
"429": "Trop de feedbacks envoyés récemment. Réessayez plus tard.",
"400": "Feedback invalide. Vérifiez le contenu.",
"generic": "Erreur lors de l'envoi. Réessayez plus tard."
}
},
"consent": {
"title": "Envoi de feedback",
"body": "Votre feedback sera envoyé à feedback.lacompagniemaximus.com pour que nous puissions améliorer l'application. Cette opération nécessite une connexion Internet et constitue une exception au fonctionnement 100 % local de l'application.",
"accept": "J'accepte"
}
}
}

View file

@ -8,7 +8,7 @@ import ProfileFormModal from "../components/profile/ProfileFormModal";
export default function ProfileSelectionPage() {
const { t } = useTranslation();
const { profiles, switchProfile } = useProfile();
const { profiles, switchProfile, updateProfile } = useProfile();
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
@ -23,8 +23,15 @@ export default function ProfileSelectionPage() {
}
};
const handlePinSuccess = () => {
const handlePinSuccess = async (rehashed?: string | null) => {
if (pinProfileId) {
if (rehashed) {
try {
await updateProfile(pinProfileId, { pin_hash: rehashed });
} catch {
// Best-effort rehash: don't block profile switch if persistence fails
}
}
switchProfile(pinProfileId);
setPinProfileId(null);
}

View file

@ -0,0 +1,120 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
import KpiCard from "../components/reports/cards/KpiCard";
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
import TopMoversList from "../components/reports/cards/TopMoversList";
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
import SeasonalityCard from "../components/reports/cards/SeasonalityCard";
import { useCartes } from "../hooks/useCartes";
export default function ReportsCartesPage() {
const { t } = useTranslation();
const {
year,
month,
snapshot,
isLoading,
error,
setReferencePeriod,
period,
setPeriod,
from,
to,
setCustomDates,
} = useCartes();
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.cartes")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{!snapshot ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
) : (
<div className="flex flex-col gap-4">
<section
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3"
aria-label={t("reports.cartes.kpiSectionAria")}
>
<KpiCard
id="income"
title={t("reports.cartes.income")}
kpi={snapshot.kpis.income}
format="currency"
deltaIsBadWhenUp={false}
/>
<KpiCard
id="expenses"
title={t("reports.cartes.expenses")}
kpi={snapshot.kpis.expenses}
format="currency"
deltaIsBadWhenUp={true}
/>
<KpiCard
id="net"
title={t("reports.cartes.net")}
kpi={snapshot.kpis.net}
format="currency"
deltaIsBadWhenUp={false}
/>
<KpiCard
id="savingsRate"
title={t("reports.cartes.savingsRate")}
kpi={snapshot.kpis.savingsRate}
format="percent"
deltaIsBadWhenUp={false}
/>
</section>
<IncomeExpenseOverlayChart flow={snapshot.flow12Months} />
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<TopMoversList movers={snapshot.topMoversUp} direction="up" />
<TopMoversList movers={snapshot.topMoversDown} direction="down" />
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<BudgetAdherenceCard adherence={snapshot.budgetAdherence} />
<SeasonalityCard
seasonality={snapshot.seasonality}
referenceYear={year}
referenceMonth={month}
/>
</section>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,114 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryZoomHeader from "../components/reports/CategoryZoomHeader";
import CategoryDonutChart from "../components/reports/CategoryDonutChart";
import CategoryEvolutionChart from "../components/reports/CategoryEvolutionChart";
import CategoryTransactionsTable from "../components/reports/CategoryTransactionsTable";
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import { useCategoryZoom } from "../hooks/useCategoryZoom";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import type { RecentTransaction } from "../shared/types";
export default function ReportsCategoryPage() {
const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const {
zoomedCategoryId,
rollupChildren,
data,
isLoading,
error,
setCategory,
setRollupChildren,
refetch,
} = useCategoryZoom();
const [pending, setPending] = useState<RecentTransaction | null>(null);
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const centerLabel = t("reports.category.breakdown");
const centerValue = data
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(data.rollupTotal)
: "—";
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.categoryZoom")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
</div>
<div className="mb-4">
<CategoryZoomHeader
categoryId={zoomedCategoryId}
includeSubcategories={rollupChildren}
onCategoryChange={setCategory}
onIncludeSubcategoriesChange={setRollupChildren}
/>
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{zoomedCategoryId === null ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.category.selectCategory")}
</div>
) : data ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<CategoryDonutChart
byChild={data.byChild}
centerLabel={centerLabel}
centerValue={centerValue}
/>
<CategoryEvolutionChart data={data.monthlyEvolution} />
<div className="lg:col-span-2">
<h3 className="text-sm font-semibold mb-2">{t("reports.category.transactions")}</h3>
<CategoryTransactionsTable
transactions={data.transactions}
onAddKeyword={(tx) => setPending(tx)}
/>
</div>
</div>
) : null}
{pending && (
<AddKeywordDialog
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
initialCategoryId={zoomedCategoryId}
onClose={() => setPending(null)}
onApplied={() => {
setPending(null);
refetch();
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,116 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CompareModeTabs from "../components/reports/CompareModeTabs";
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
import ComparePeriodTable from "../components/reports/ComparePeriodTable";
import ComparePeriodChart from "../components/reports/ComparePeriodChart";
import CompareBudgetView from "../components/reports/CompareBudgetView";
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
import { useCompare, comparisonMeta } from "../hooks/useCompare";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
const STORAGE_KEY = "reports-viewmode-compare";
function formatMonthLabel(year: number, month: number, language: string): string {
const date = new Date(year, month - 1, 1);
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(date);
}
export default function ReportsComparePage() {
const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const {
mode,
subMode,
setMode,
setSubMode,
setReferencePeriod,
year,
month,
rows,
isLoading,
error,
} = useCompare();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const { previousYear, previousMonth: prevMonth } = comparisonMeta(subMode, year, month);
const currentLabel =
subMode === "mom" ? formatMonthLabel(year, month, i18n.language) : String(year);
const previousLabel =
subMode === "mom"
? formatMonthLabel(previousYear, prevMonth, i18n.language)
: String(previousYear);
const showActualControls = mode === "actual";
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.compare")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<div className="flex gap-2 items-center flex-wrap">
<CompareModeTabs value={mode} onChange={setMode} />
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<CompareReferenceMonthPicker
year={year}
month={month}
onChange={setReferencePeriod}
/>
{showActualControls && (
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
)}
</div>
{showActualControls && (
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
)}
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{mode === "budget" ? (
<CompareBudgetView year={year} month={month} />
) : viewMode === "chart" ? (
<ComparePeriodChart
rows={rows}
previousLabel={previousLabel}
currentLabel={currentLabel}
/>
) : (
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
)}
</div>
);
}

View file

@ -0,0 +1,131 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft, Tag } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import HubNetBalanceTile from "../components/reports/HubNetBalanceTile";
import HighlightsTopMoversTable from "../components/reports/HighlightsTopMoversTable";
import HighlightsTopMoversChart from "../components/reports/HighlightsTopMoversChart";
import HighlightsTopTransactionsList from "../components/reports/HighlightsTopTransactionsList";
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
import ContextMenu from "../components/shared/ContextMenu";
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import { useHighlights } from "../hooks/useHighlights";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import type { RecentTransaction } from "../shared/types";
const STORAGE_KEY = "reports-viewmode-highlights";
export default function ReportsHighlightsPage() {
const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { data, isLoading, error, windowDays, setWindowDays } = useHighlights();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
const [pending, setPending] = useState<RecentTransaction | null>(null);
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, tx });
};
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.highlights")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{data && (
<div className="flex flex-col gap-6">
<section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.highlights.balances")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<HubNetBalanceTile
label={t("reports.highlights.netBalanceCurrent")}
amount={data.netBalanceCurrent}
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
/>
<HubNetBalanceTile
label={t("reports.highlights.netBalanceYtd")}
amount={data.netBalanceYtd}
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
/>
</div>
</section>
<section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.highlights.topMovers")}
</h2>
{viewMode === "chart" ? (
<HighlightsTopMoversChart movers={data.topMovers} />
) : (
<HighlightsTopMoversTable movers={data.topMovers} />
)}
</section>
<section>
<HighlightsTopTransactionsList
transactions={data.topTransactions}
windowDays={windowDays}
onWindowChange={setWindowDays}
onContextMenuRow={handleContextMenu}
/>
</section>
</div>
)}
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
header={menu.tx.description}
onClose={() => setMenu(null)}
items={[
{
icon: <Tag size={14} />,
label: t("reports.keyword.addFromTransaction"),
onClick: () => setPending(menu.tx),
},
]}
/>
)}
{pending && (
<AddKeywordDialog
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
onClose={() => setPending(null)}
onApplied={() => setPending(null)}
/>
)}
</div>
);
}

View file

@ -1,257 +1,79 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Hash, Table, BarChart3 } from "lucide-react";
import { useReports } from "../hooks/useReports";
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
import type { ReportTab, CategoryBreakdownItem, ImportSource } from "../shared/types";
import { getAllSources } from "../services/importSourceService";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoryTable from "../components/reports/CategoryTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import DynamicReport from "../components/reports/DynamicReport";
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
import HubReportNavCard from "../components/reports/HubReportNavCard";
import { useHighlights } from "../hooks/useHighlights";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
export default function ReportsPage() {
const { t, i18n } = useTranslation();
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType } = useReports();
const [sources, setSources] = useState<ImportSource[]>([]);
const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { data, isLoading, error } = useHighlights();
useEffect(() => {
getAllSources().then(setSources);
}, []);
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
const [showAmounts, setShowAmounts] = useState(() => localStorage.getItem("reports-show-amounts") === "true");
const [viewMode, setViewMode] = useState<"chart" | "table">(() =>
(localStorage.getItem("reports-view-mode") as "chart" | "table") || "chart"
);
const toggleHidden = useCallback((name: string) => {
setHiddenCategories((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
const viewDetails = useCallback((item: CategoryBreakdownItem) => {
setDetailModal(item);
}, []);
const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo);
const filterCategories = useMemo(() => {
if (state.tab === "byCategory") {
return state.categorySpending.map((c) => ({ name: c.category_name, color: c.category_color }));
}
if (state.tab === "overTime") {
return state.categoryOverTime.categories.map((name) => ({
name,
color: state.categoryOverTime.colors[name] || "#9ca3af",
}));
}
return [];
}, [state.tab, state.categorySpending, state.categoryOverTime]);
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const navCards = [
{
to: `/reports/highlights${preserveSearch}`,
icon: <Sparkles size={24} />,
title: t("reports.hub.highlights"),
description: t("reports.hub.highlightsDescription"),
},
{
to: `/reports/trends${preserveSearch}`,
icon: <TrendingUp size={24} />,
title: t("reports.hub.trends"),
description: t("reports.hub.trendsDescription"),
},
{
to: `/reports/compare${preserveSearch}`,
icon: <Scale size={24} />,
title: t("reports.hub.compare"),
description: t("reports.hub.compareDescription"),
},
{
to: `/reports/category${preserveSearch}`,
icon: <Search size={24} />,
title: t("reports.hub.categoryZoom"),
description: t("reports.hub.categoryZoomDescription"),
},
{
to: `/reports/cartes${preserveSearch}`,
icon: <LayoutDashboard size={24} />,
title: t("reports.hub.cartes"),
description: t("reports.hub.cartesDescription"),
},
];
return (
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div>
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
{state.tab === "budgetVsActual" ? (
<h1 className="text-2xl font-bold flex items-center gap-2 flex-wrap">
{t("reports.bva.titlePrefix")}
<select
value={`${state.budgetYear}-${state.budgetMonth}`}
onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number);
setBudgetMonth(y, m);
}}
className="text-lg font-bold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
>
{monthOptions.map((opt) => (
<option key={opt.key} value={opt.value}>
{opt.label}
</option>
))}
</select>
</h1>
) : (
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
)}
<h1 className="text-2xl font-bold">{t("reports.hub.title")}</h1>
<PageHelp helpKey="reports" />
</div>
{state.tab !== "budgetVsActual" && (
<PeriodSelector
value={state.period}
onChange={setPeriod}
customDateFrom={state.customDateFrom}
customDateTo={state.customDateTo}
onCustomDateChange={setCustomDates}
/>
)}
</div>
<div className="flex gap-2 mb-6 flex-wrap items-center">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setTab(tab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === state.tab
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(`reports.${tab}`)}
</button>
))}
{["trends", "byCategory", "overTime"].includes(state.tab) && (
<>
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
{([
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
]).map(({ mode, icon, label }) => (
<button
key={mode}
onClick={() => {
setViewMode(mode);
localStorage.setItem("reports-view-mode", mode);
}}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
mode === viewMode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
{viewMode === "chart" && (
<>
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
<button
onClick={() => {
setShowAmounts((prev) => {
const next = !prev;
localStorage.setItem("reports-show-amounts", String(next));
return next;
});
}}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
showAmounts
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
title={showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
>
<Hash size={14} />
{showAmounts ? t("reports.detail.hideAmounts") : t("reports.detail.showAmounts")}
</button>
</>
)}
</>
)}
</div>
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}
</div>
)}
<div className={showFilterPanel ? "flex gap-4 items-start" : ""}>
<div className={showFilterPanel ? "flex-1 min-w-0" : ""}>
{state.tab === "trends" && (
viewMode === "chart" ? (
<MonthlyTrendsChart data={state.monthlyTrends} showAmounts={showAmounts} />
) : (
<MonthlyTrendsTable data={state.monthlyTrends} />
)
)}
{state.tab === "byCategory" && (
viewMode === "chart" ? (
<CategoryBarChart
data={state.categorySpending}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
showAmounts={showAmounts}
/>
) : (
<CategoryTable data={state.categorySpending} hiddenCategories={hiddenCategories} />
)
)}
{state.tab === "overTime" && (
viewMode === "chart" ? (
<CategoryOverTimeChart
data={state.categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
showAmounts={showAmounts}
/>
) : (
<CategoryOverTimeTable data={state.categoryOverTime} hiddenCategories={hiddenCategories} />
)
)}
{state.tab === "budgetVsActual" && (
<BudgetVsActualTable data={state.budgetVsActual} />
)}
{state.tab === "dynamic" && (
<DynamicReport
config={state.pivotConfig}
result={state.pivotResult}
onConfigChange={setPivotConfig}
/>
)}
</div>
{showFilterPanel && (
<ReportFilterPanel
categories={filterCategories}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
sources={sources}
selectedSourceId={state.sourceId}
onSourceChange={setSourceId}
categoryType={state.tab === "overTime" ? state.categoryType : undefined}
onCategoryTypeChange={state.tab === "overTime" ? setCategoryType : undefined}
/>
)}
</div>
{detailModal && (
<TransactionDetailModal
categoryId={detailModal.category_id}
categoryName={detailModal.category_name}
categoryColor={detailModal.category_color}
dateFrom={dateFrom}
dateTo={dateTo}
onClose={() => setDetailModal(null)}
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
)}
</div>
<HubHighlightsPanel data={data} isLoading={isLoading} error={error} />
<section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.hub.explore")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
{navCards.map((card) => (
<HubReportNavCard key={card.to} {...card} />
))}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,116 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
import { useTrends } from "../hooks/useTrends";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import type { CategoryBreakdownItem } from "../shared/types";
const STORAGE_KEY = "reports-viewmode-trends";
export default function ReportsTrendsPage() {
const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const toggleHidden = useCallback((name: string) => {
setHiddenCategories((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
// viewDetails not used in trends view — transactions details are accessed from category zoom.
const noOpDetails = useCallback((_item: CategoryBreakdownItem) => {}, []);
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<div className="flex gap-2 items-center flex-wrap">
<div className="inline-flex gap-1">
<button
type="button"
onClick={() => setSubView("global")}
aria-pressed={subView === "global"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "global"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t("reports.trends.subviewGlobal")}
</button>
<button
type="button"
onClick={() => setSubView("byCategory")}
aria-pressed={subView === "byCategory"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "byCategory"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t("reports.trends.subviewByCategory")}
</button>
</div>
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div>
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{subView === "global" ? (
viewMode === "chart" ? (
<MonthlyTrendsChart data={monthlyTrends} />
) : (
<MonthlyTrendsTable data={monthlyTrends} />
)
) : viewMode === "chart" ? (
<CategoryOverTimeChart
data={categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={noOpDetails}
/>
) : (
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
)}
</div>
);
}

View file

@ -20,7 +20,9 @@ import { APP_NAME } from "../shared/constants";
import { PageHelp } from "../components/shared/PageHelp";
import DataManagementCard from "../components/settings/DataManagementCard";
import LicenseCard from "../components/settings/LicenseCard";
import AccountCard from "../components/settings/AccountCard";
import LogViewerCard from "../components/settings/LogViewerCard";
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
@ -76,6 +78,13 @@ export default function SettingsPage() {
{/* License card */}
<LicenseCard />
{/* Account card */}
<AccountCard />
{/* Security banner renders only when OAuth tokens are in the
file fallback instead of the OS keychain */}
<TokenStoreFallbackBanner />
{/* About card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<div className="flex items-center gap-4">
@ -159,6 +168,14 @@ export default function SettingsPage() {
</div>
)}
{/* not entitled (free edition) */}
{state.status === "notEntitled" && (
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.updates.notEntitled")}</p>
</div>
)}
{/* up to date */}
{state.status === "upToDate" && (
<div className="flex items-center justify-between">

View file

@ -1,18 +1,28 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { Wand2, Tag } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
import { useTransactions } from "../hooks/useTransactions";
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
import TransactionTable from "../components/transactions/TransactionTable";
import TransactionPagination from "../components/transactions/TransactionPagination";
import ContextMenu from "../components/shared/ContextMenu";
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import type { TransactionRow } from "../shared/types";
export default function TransactionsPage() {
const { t } = useTranslation();
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
useTransactions();
const [resultMessage, setResultMessage] = useState<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
const [pending, setPending] = useState<TransactionRow | null>(null);
const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, row });
};
const handleAutoCategorize = async () => {
setResultMessage(null);
@ -84,6 +94,7 @@ export default function TransactionsPage() {
onLoadSplitChildren={loadSplitChildren}
onSaveSplit={saveSplit}
onDeleteSplit={deleteSplit}
onRowContextMenu={handleRowContextMenu}
/>
<TransactionPagination
@ -94,6 +105,31 @@ export default function TransactionsPage() {
/>
</>
)}
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
header={menu.row.description}
onClose={() => setMenu(null)}
items={[
{
icon: <Tag size={14} />,
label: t("reports.keyword.addFromTransaction"),
onClick: () => setPending(menu.row),
},
]}
/>
)}
{pending && (
<AddKeywordDialog
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
initialCategoryId={pending.category_id}
onClose={() => setPending(null)}
onApplied={() => setPending(null)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,34 @@
import { invoke } from "@tauri-apps/api/core";
export interface AccountInfo {
email: string;
name: string | null;
picture: string | null;
subscription_status: string | null;
}
export async function startOAuth(): Promise<string> {
return invoke<string>("start_oauth");
}
export async function refreshAuthToken(): Promise<AccountInfo> {
return invoke<AccountInfo>("refresh_auth_token");
}
export async function getAccountInfo(): Promise<AccountInfo | null> {
return invoke<AccountInfo | null>("get_account_info");
}
export async function checkSubscriptionStatus(): Promise<AccountInfo | null> {
return invoke<AccountInfo | null>("check_subscription_status");
}
export async function logoutAccount(): Promise<void> {
return invoke<void>("logout");
}
export type TokenStoreMode = "keychain" | "file";
export async function getTokenStoreMode(): Promise<TokenStoreMode | null> {
return invoke<TokenStoreMode | null>("get_token_store_mode");
}

View file

@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
validateKeyword,
previewKeywordMatches,
applyKeywordWithReassignment,
KEYWORD_MAX_LENGTH,
} from "./categorizationService";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
mockExecute.mockReset();
});
describe("validateKeyword", () => {
it("rejects whitespace-only input", () => {
expect(validateKeyword(" ")).toEqual({ ok: false, reason: "tooShort" });
});
it("rejects single-character keywords", () => {
expect(validateKeyword("a")).toEqual({ ok: false, reason: "tooShort" });
});
it("accepts a minimal two-character keyword after trim", () => {
expect(validateKeyword(" ab ")).toEqual({ ok: true, value: "ab" });
});
it("rejects keywords longer than 64 characters (ReDoS cap)", () => {
const long = "a".repeat(KEYWORD_MAX_LENGTH + 1);
expect(validateKeyword(long)).toEqual({ ok: false, reason: "tooLong" });
});
it("accepts keywords at exactly 64 characters", () => {
const borderline = "a".repeat(KEYWORD_MAX_LENGTH);
expect(validateKeyword(borderline)).toEqual({ ok: true, value: borderline });
});
});
describe("previewKeywordMatches", () => {
it("binds the LIKE pattern as a parameter (no interpolation)", async () => {
mockSelect.mockResolvedValueOnce([]);
await previewKeywordMatches("METRO");
expect(mockSelect).toHaveBeenCalledTimes(1);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("LIKE $1");
expect(sql).not.toContain("'metro'");
expect(sql).not.toContain("'%metro%'");
expect(params[0]).toBe("%metro%");
});
it("returns an empty preview when the keyword is invalid", async () => {
const result = await previewKeywordMatches("a");
expect(result).toEqual({ visible: [], totalMatches: 0 });
expect(mockSelect).not.toHaveBeenCalled();
});
it("filters candidates with the regex and respects the visible cap", async () => {
// 3 rows: 2 true matches, 1 substring-only (should be dropped by word-boundary)
mockSelect.mockResolvedValueOnce([
{ id: 1, date: "2026-03-15", description: "METRO #123", amount: -45, category_name: null, category_color: null },
{ id: 2, date: "2026-03-02", description: "METRO PLUS", amount: -67.2, category_name: null, category_color: null },
{ id: 3, date: "2026-02-18", description: "METROPOLITAIN", amount: -12, category_name: null, category_color: null },
]);
const result = await previewKeywordMatches("METRO", 2);
expect(result.totalMatches).toBe(2);
expect(result.visible).toHaveLength(2);
expect(result.visible[0].id).toBe(1);
expect(result.visible[1].id).toBe(2);
});
});
describe("applyKeywordWithReassignment", () => {
it("wraps INSERT + UPDATEs in a BEGIN/COMMIT transaction", async () => {
mockSelect.mockResolvedValueOnce([]); // existing keyword lookup → none
mockExecute.mockResolvedValue({ lastInsertId: 77, rowsAffected: 1 });
await applyKeywordWithReassignment({
keyword: "METRO",
categoryId: 3,
transactionIds: [1, 2],
allowReplaceExisting: false,
});
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
expect(commands[0]).toBe("BEGIN");
expect(commands[commands.length - 1]).toBe("COMMIT");
expect(commands.filter((c) => c.startsWith("INSERT INTO keywords"))).toHaveLength(1);
expect(commands.filter((c) => c.startsWith("UPDATE transactions"))).toHaveLength(2);
});
it("rolls back when an UPDATE throws", async () => {
mockSelect.mockResolvedValueOnce([]);
mockExecute
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ lastInsertId: 77 }) // INSERT keywords
.mockRejectedValueOnce(new Error("disk full")); // UPDATE transactions fails
await expect(
applyKeywordWithReassignment({
keyword: "METRO",
categoryId: 3,
transactionIds: [1],
allowReplaceExisting: false,
}),
).rejects.toThrow("disk full");
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
expect(commands).toContain("BEGIN");
expect(commands).toContain("ROLLBACK");
expect(commands).not.toContain("COMMIT");
});
it("blocks reassignment when keyword exists for another category without allowReplaceExisting", async () => {
mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]);
await expect(
applyKeywordWithReassignment({
keyword: "METRO",
categoryId: 3,
transactionIds: [1],
allowReplaceExisting: false,
}),
).rejects.toThrow("keyword_already_exists");
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
expect(commands).toContain("BEGIN");
expect(commands).toContain("ROLLBACK");
});
it("reassigns existing keyword when allowReplaceExisting is true", async () => {
mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]);
mockExecute.mockResolvedValue({ rowsAffected: 1 });
const result = await applyKeywordWithReassignment({
keyword: "METRO",
categoryId: 3,
transactionIds: [1, 2],
allowReplaceExisting: true,
});
expect(result.replacedExisting).toBe(true);
expect(result.updatedTransactions).toBe(2);
const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim());
expect(commands.some((c) => c.startsWith("UPDATE keywords SET category_id"))).toBe(true);
expect(commands).toContain("COMMIT");
});
it("rejects invalid keywords before touching the database", async () => {
await expect(
applyKeywordWithReassignment({
keyword: "a",
categoryId: 3,
transactionIds: [1],
allowReplaceExisting: false,
}),
).rejects.toThrow("invalid_keyword:tooShort");
expect(mockExecute).not.toHaveBeenCalled();
});
});

View file

@ -1,5 +1,5 @@
import { getDb } from "./db";
import type { Keyword } from "../shared/types";
import type { Keyword, RecentTransaction } from "../shared/types";
/**
* Normalize a description for keyword matching:
@ -7,7 +7,7 @@ import type { Keyword } from "../shared/types";
* - strip accents via NFD decomposition
* - collapse whitespace
*/
function normalizeDescription(desc: string): string {
export function normalizeDescription(desc: string): string {
return desc
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
@ -25,7 +25,7 @@ const WORD_CHAR = /\w/;
* (e.g., brackets, parentheses, dashes). This ensures keywords like
* "[VIREMENT]" or "(INTERAC)" can match correctly.
*/
function buildKeywordRegex(normalizedKeyword: string): RegExp {
export function buildKeywordRegex(normalizedKeyword: string): RegExp {
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const left = WORD_CHAR.test(normalizedKeyword[0])
? "\\b"
@ -50,7 +50,7 @@ interface CompiledKeyword {
/**
* Compile keywords into regex patterns once for reuse across multiple matches.
*/
function compileKeywords(keywords: Keyword[]): CompiledKeyword[] {
export function compileKeywords(keywords: Keyword[]): CompiledKeyword[] {
return keywords.map((kw) => ({
regex: buildKeywordRegex(normalizeDescription(kw.keyword)),
category_id: kw.category_id,
@ -112,3 +112,162 @@ export async function categorizeBatch(
return matchDescription(normalized, compiled);
});
}
// --- AddKeywordDialog support (Issue #74) ---
export const KEYWORD_MIN_LENGTH = 2;
export const KEYWORD_MAX_LENGTH = 64;
export const KEYWORD_PREVIEW_LIMIT = 50;
/**
* Validate a keyword before it hits the regex engine.
*
* Rejects whitespace-only input and caps length at 64 chars to prevent
* ReDoS (CWE-1333) when the compiled regex is replayed across many
* transactions later.
*/
export function validateKeyword(raw: string): { ok: true; value: string } | { ok: false; reason: "tooShort" | "tooLong" } {
const trimmed = raw.trim();
if (trimmed.length < KEYWORD_MIN_LENGTH) return { ok: false, reason: "tooShort" };
if (trimmed.length > KEYWORD_MAX_LENGTH) return { ok: false, reason: "tooLong" };
return { ok: true, value: trimmed };
}
/**
* Preview the transactions that would be recategorised if the user commits
* the given keyword. Uses a parameterised `LIKE ?1` to scope the candidates,
* then re-filters in memory with `buildKeywordRegex` for exact word-boundary
* matching. Results are capped at `limit` visible rows callers decide what
* to do with the `totalMatches` (which may be greater than the returned list).
*
* SECURITY: the keyword is never interpolated into the SQL string. `LIKE ?1`
* is the only parameterised binding, and the `%...%` wrapping happens inside
* the bound parameter value.
*/
export async function previewKeywordMatches(
keyword: string,
limit: number = KEYWORD_PREVIEW_LIMIT,
): Promise<{ visible: RecentTransaction[]; totalMatches: number }> {
const validation = validateKeyword(keyword);
if (!validation.ok) {
return { visible: [], totalMatches: 0 };
}
const normalized = normalizeDescription(validation.value);
const regex = buildKeywordRegex(normalized);
const db = await getDb();
// Coarse pre-filter via parameterised LIKE (case-insensitive thanks to
// normalize on the JS side). A small cap protects against catastrophic
// backtracking across a huge candidate set — hard-capped to 1000 rows
// before the in-memory filter.
const likePattern = `%${normalized}%`;
const candidates = await db.select<RecentTransaction[]>(
`SELECT t.id, t.date, t.description, t.amount,
c.name AS category_name,
c.color AS category_color
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE LOWER(t.description) LIKE $1
ORDER BY t.date DESC
LIMIT 1000`,
[likePattern],
);
const matched: RecentTransaction[] = [];
for (const tx of candidates) {
const normDesc = normalizeDescription(tx.description);
if (regex.test(normDesc)) matched.push(tx);
}
return {
visible: matched.slice(0, limit),
totalMatches: matched.length,
};
}
export interface ApplyKeywordInput {
keyword: string;
categoryId: number;
/** ids of transactions to recategorise (only those the user checked). */
transactionIds: number[];
/**
* When true, and a keyword with the same spelling already exists for a
* different category, that existing keyword is **reassigned** to the new
* category rather than creating a duplicate. Matches the spec decision
* that history is never touched only the visible transactions are
* recategorised.
*/
allowReplaceExisting: boolean;
}
export interface ApplyKeywordResult {
keywordId: number;
updatedTransactions: number;
replacedExisting: boolean;
}
/**
* INSERTs (or reassigns) a keyword and recategorises the given transaction
* ids in a single SQL transaction. Either all writes commit or none do.
*
* SECURITY: every query is parameterised. The caller is expected to have
* vetted `transactionIds` from a preview window that the user confirmed.
*/
export async function applyKeywordWithReassignment(
input: ApplyKeywordInput,
): Promise<ApplyKeywordResult> {
const validation = validateKeyword(input.keyword);
if (!validation.ok) {
throw new Error(`invalid_keyword:${validation.reason}`);
}
const keyword = validation.value;
const db = await getDb();
await db.execute("BEGIN");
try {
// Is there already a row for this keyword spelling?
const existing = await db.select<Array<{ id: number; category_id: number }>>(
`SELECT id, category_id FROM keywords WHERE keyword = $1 LIMIT 1`,
[keyword],
);
let keywordId: number;
let replacedExisting = false;
if (existing.length > 0) {
if (!input.allowReplaceExisting && existing[0].category_id !== input.categoryId) {
throw new Error("keyword_already_exists");
}
await db.execute(
`UPDATE keywords SET category_id = $1, is_active = 1 WHERE id = $2`,
[input.categoryId, existing[0].id],
);
keywordId = existing[0].id;
replacedExisting = existing[0].category_id !== input.categoryId;
} else {
const result = await db.execute(
`INSERT INTO keywords (keyword, category_id, priority) VALUES ($1, $2, $3)`,
[keyword, input.categoryId, 100],
);
keywordId = Number(result.lastInsertId ?? 0);
}
let updatedTransactions = 0;
for (const txId of input.transactionIds) {
await db.execute(
`UPDATE transactions
SET category_id = $1,
is_manually_categorized = 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[input.categoryId, txId],
);
updatedTransactions++;
}
await db.execute("COMMIT");
return { keywordId, updatedTransactions, replacedExisting };
} catch (e) {
await db.execute("ROLLBACK");
throw e;
}
}

View file

@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { isFeedbackErrorCode } from "./feedbackService";
describe("isFeedbackErrorCode", () => {
it("recognizes the four stable error codes", () => {
expect(isFeedbackErrorCode("invalid")).toBe(true);
expect(isFeedbackErrorCode("rate_limit")).toBe(true);
expect(isFeedbackErrorCode("server_error")).toBe(true);
expect(isFeedbackErrorCode("network_error")).toBe(true);
});
it("rejects unknown strings and non-strings", () => {
expect(isFeedbackErrorCode("boom")).toBe(false);
expect(isFeedbackErrorCode("")).toBe(false);
expect(isFeedbackErrorCode(404)).toBe(false);
expect(isFeedbackErrorCode(null)).toBe(false);
expect(isFeedbackErrorCode(undefined)).toBe(false);
expect(isFeedbackErrorCode({ error: "rate_limit" })).toBe(false);
});
});

View file

@ -0,0 +1,50 @@
import { invoke } from "@tauri-apps/api/core";
export type FeedbackErrorCode =
| "invalid"
| "rate_limit"
| "server_error"
| "network_error";
export interface FeedbackContext {
page?: string;
locale?: string;
theme?: string;
viewport?: string;
userAgent?: string;
timestamp?: string;
}
export interface FeedbackSuccess {
id: string;
created_at: string;
}
export interface SendFeedbackInput {
content: string;
userId?: string | null;
context?: FeedbackContext;
}
export async function sendFeedback(
input: SendFeedbackInput,
): Promise<FeedbackSuccess> {
return invoke<FeedbackSuccess>("send_feedback", {
content: input.content,
userId: input.userId ?? null,
context: input.context ?? null,
});
}
export async function getFeedbackUserAgent(): Promise<string> {
return invoke<string>("get_feedback_user_agent");
}
export function isFeedbackErrorCode(value: unknown): value is FeedbackErrorCode {
return (
value === "invalid" ||
value === "rate_limit" ||
value === "server_error" ||
value === "network_error"
);
}

Some files were not shown because too many files have changed in this diff Show more