Compare commits

..

71 commits

Author SHA1 Message Date
f3af3d7c1b Merge pull request 'feat(categories): v1 IPC seed + i18n keys + migration v8 (#115)' (#125) from issue-115-seed-v1-i18n into main 2026-04-19 20:51:16 +00:00
63feebefc8 Merge pull request 'feat(categories): categoryBackupService pre-migration SREF wrapper (#120)' (#124) from issue-120-category-backup-service into main 2026-04-19 20:48:51 +00:00
le king fu
bd992f2f94 feat(categories): add v1 IPC seed, i18n keys, and migration v8 (#115)
All checks were successful
PR Check / rust (push) Successful in 22m29s
PR Check / frontend (push) Successful in 2m18s
PR Check / rust (pull_request) Successful in 22m39s
PR Check / frontend (pull_request) Successful in 2m18s
Livraison 1 du milestone spec-refonte-seed-categories-ipc. Applies the
new v1 IPC (Indice des prix à la consommation) taxonomy to freshly
created profiles while leaving existing v2 profiles untouched until the
migration wizard (upcoming issue #121) prompts them to move.

- Migration v8 (additive only):
    - ALTER TABLE categories ADD COLUMN i18n_key TEXT
    - INSERT OR IGNORE user_preferences.categories_schema_version=v2
      (existing profiles tagged as v2 for later migration)
- consolidated_schema.sql rewritten with the full v1 seed and
  categories_schema_version='v1' default for brand-new profiles
- src/data/categoryTaxonomyV1.json bundled as the TS-side source of
  truth (consumed by #116 categoryTaxonomyService next)
- categoriesSeed.* i18n namespace (FR/EN) — 150 entries each
- CategoryTree and CategoryCombobox fall back to the raw `name` when
  i18n_key is null (user-created categories stay literal)
- CategoryTreeNode and CategoryRow gain the i18n_key field end-to-end

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:41:55 -04:00
le king fu
3c628d4cd1 feat(categories): add categoryBackupService for pre-migration SREF backup (#120)
All checks were successful
PR Check / rust (push) Successful in 22m20s
PR Check / frontend (push) Successful in 2m18s
PR Check / rust (pull_request) Successful in 22m1s
PR Check / frontend (pull_request) Successful in 2m16s
Wrapper around dataExportService that creates and verifies a full SREF
backup before the v2->v1 categories migration. Throws on any failure to
ensure migration aborts cleanly.

- Generates filename <ProfileName>_avant-migration-<ISO8601>.sref
- Writes to ~/Documents/Simpl-Resultat/backups/ (creates dir if missing)
- Verifies integrity via re-read + SHA-256 checksum
- Reuses profile PIN for encryption when protected
- Adds two minimal Tauri commands: ensure_backup_dir, get_file_size
- Stable error codes (BackupError) to map to i18n keys in the UI layer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:46:23 -04:00
le king fu
0e2078088a docs: add spec decisions and plan for categories IPC seed refactor
Source of truth for milestone spec-refonte-seed-categories-ipc
(issues #115-#123).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:35:15 -04:00
le king fu
0af5dd95cc chore: release v0.8.3
All checks were successful
Release / build-and-release (push) Successful in 23m6s
2026-04-19 10:01:29 -04:00
f371ae3f7e Merge pull request 'feat(reports/cartes): Mensuel/YTD toggle on KPI cards + user guide section (#102)' (#114) from issue-102-cartes-ytd-toggle-docs into main 2026-04-19 13:55:35 +00:00
le king fu
3be05db41a feat(reports/cartes): Mensuel/YTD toggle on KPI cards + user guide section (#102)
All checks were successful
PR Check / rust (push) Successful in 21m48s
PR Check / frontend (push) Successful in 2m15s
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m16s
Adds a segmented Monthly/YTD toggle next to the reference-month picker that
flips the four KPI cards (income, expenses, net, savings rate) between the
reference-month value (unchanged default) and a Year-to-Date cumulative view.

In YTD mode, the "current" value sums January to the reference month of the
reference year; MoM delta compares it to Jan to (refMonth - 1) of the same
year (null in January, since no prior YTD window exists); YoY delta compares
it to Jan to refMonth of the previous year; savings rate is recomputed from
YTD income and expenses, and stays null when YTD income is zero.

The 13-month sparkline, top movers, seasonality and budget adherence cards
remain monthly regardless of the toggle (by design). The savings-rate tooltip
is now dynamic and mirrors the active mode. The mode is persisted in
localStorage under `reports-cartes-period-mode`.

Also adds a dedicated Cartes section to `docs/guide-utilisateur.md` covering
the four KPI formulas, the Monthly/YTD toggle and its effect on deltas, the
sparkline, top movers, seasonality, budget adherence and the savings-rate
edge case. Mirrored in the in-app `docs.reports` i18n tree (features/steps/
tips extended) for both FR and EN.

No SQL migration: YTD sums are computed from the already-fetched
`flowByMonth` map, so no extra round trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:49:21 -04:00
d834ffeb9c Merge pull request 'fix(reports/cartes): Budget Adherence card filtered out all expense categories' (#113) from issue-112-budget-adherence-signed-fix into main 2026-04-19 13:39:11 +00:00
le king fu
2ec48d4e90 fix(reports/cartes): Budget Adherence card was filtering out all expense categories
All checks were successful
PR Check / rust (push) Successful in 21m31s
PR Check / frontend (push) Successful in 2m13s
PR Check / rust (pull_request) Successful in 22m0s
PR Check / frontend (pull_request) Successful in 2m14s
Expense budgets are stored signed-negative by budgetService. The Cartes
budget-adherence card used raw values in its filter (monthBudget > 0),
its in-target comparison (|actual| <= monthBudget), and its overrun
calculation — all of which silently rejected every expense row. Route
every amount through Math.abs() so the card reflects real budget data.

Test: regression fixture with a signed-negative monthBudget that must
pass the filter and count as in-target or overrun based on absolute
values.

Fixes #112
2026-04-19 09:35:26 -04:00
da6041dc45 Merge pull request 'Highlights: default reference month to previous + YTD current year, user-changeable (#106)' (#111) from issue-106-highlights-default-ref-month into main 2026-04-19 12:34:37 +00:00
le king fu
8b90cb6489 feat(reports/highlights): default reference month to previous month + YTD current year, user-changeable (#106)
All checks were successful
PR Check / rust (push) Successful in 21m14s
PR Check / frontend (push) Successful in 2m16s
PR Check / rust (pull_request) Successful in 21m31s
PR Check / frontend (pull_request) Successful in 2m14s
- Extract shared defaultReferencePeriod helper (src/utils/referencePeriod.ts)
- useHighlights now reads ?refY=YYYY&refM=MM, defaults to previous month
- getHighlights signature: (referenceYear, referenceMonth, ytdYear, windowDays, ...)
- YTD tile pinned to Jan 1 of current civil year, independent of reference month
- CompareReferenceMonthPicker surfaced on /reports/highlights
- Hub highlights panel inherits the same default via useHighlights
- useCartes and useCompare now delegate their default-period helpers to the shared util
2026-04-19 08:28:30 -04:00
3842a1102a Merge pull request 'feat(reports/trends): add stacked-area chart option for category view (#105)' (#110) from issue-105-trends-stacked-area into main 2026-04-19 12:22:03 +00:00
le king fu
94104c4223 refactor(reports/trends): rename chart type from 'line' to 'bar' to match actual rendering
All checks were successful
PR Check / rust (push) Successful in 21m44s
PR Check / frontend (push) Successful in 2m18s
PR Check / rust (pull_request) Successful in 21m27s
PR Check / frontend (pull_request) Successful in 2m11s
The legacy chart was a stacked BarChart, not a LineChart — the initial 'line'
naming was misleading. Rename internal type, i18n key (chartLine -> chartBar,
Lignes -> Barres, Lines -> Bars) and icon. Legacy 'line' in localStorage is
migrated to 'bar' on read.
2026-04-19 07:26:22 -04:00
le king fu
02efc75542 feat(reports/trends): add stacked-area chart option for category view (#105)
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Successful in 21m22s
PR Check / frontend (pull_request) Successful in 2m15s
Adds a segmented chart-type toggle to the /reports/trends "By category"
sub-view that switches between the existing stacked bars (default,
unchanged) and a new Recharts AreaChart with stackId="1" showing total
composition over time. Both modes share the same category palette and
SVG grayscale patterns so the visual signature is preserved.

- CategoryOverTimeChart gains a chartType: 'line' | 'area' prop
  (default 'line' for backward compatibility with the dashboard usage).
- New TrendsChartTypeToggle component, persisted in localStorage under
  "reports-trends-category-charttype".
- Toggle only renders in the "By category" sub-view with chart view
  mode selected; hidden otherwise.
- i18n keys reports.trends.chartLine / chartArea / chartTypeAria in
  FR and EN.
- CHANGELOG entries in both languages.
2026-04-19 07:23:49 -04:00
e95612a55d Merge pull request 'feat(reports/compare): 8-column table with monthly + cumulative YTD blocks (#104)' (#109) from issue-104-compare-eight-col-table into main 2026-04-19 11:19:21 +00:00
le king fu
bd8a5732c6 feat(reports/compare): 8-column table with monthly + cumulative YTD blocks (#104)
All checks were successful
PR Check / rust (push) Successful in 21m21s
PR Check / frontend (push) Successful in 2m11s
PR Check / rust (pull_request) Successful in 21m30s
PR Check / frontend (pull_request) Successful in 2m8s
Mirror the BudgetVsActualTable structure in the Actual-vs-Actual compare
mode so MoM and YoY both surface a Monthly block (reference month vs
comparison month) and a Cumulative YTD block (progress through the
reference month vs progress through the previous window).

- CategoryDelta gains cumulative{Previous,Current}Amount and
  cumulativeDelta{Abs,Pct}. Legacy previousAmount / currentAmount /
  deltaAbs / deltaPct are kept as aliases of the monthly block so the
  Highlights hub, Cartes dashboard and ComparePeriodChart keep working
  unchanged.
- getCompareMonthOverMonth: cumulative-previous window ends at the end
  of the previous month within the SAME year; when the reference month
  is January, the previous window sits entirely in the prior calendar
  year (Jan → Dec).
- getCompareYearOverYear: now takes an optional reference month
  (defaults to December for backward compatibility). Monthly block
  compares the single reference month across years; cumulative block
  compares Jan → refMonth across years.
- ComparePeriodTable rebuilt with two colspan header groups, four
  sub-columns each, a totals row and month/year boundary sub-labels.
- ComparePeriodChart unchanged: still reads the monthly primary fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:17:32 -04:00
8d916a1283 Merge pull request 'feat(reports/category): searchable combobox for category zoom (#103)' (#108) from issue-103-category-combobox into main 2026-04-19 01:10:25 +00:00
le king fu
01869462f4 feat(reports/category): replace select with searchable combobox (#103)
All checks were successful
PR Check / rust (push) Successful in 21m2s
PR Check / frontend (push) Successful in 2m13s
PR Check / rust (pull_request) Successful in 21m14s
PR Check / frontend (pull_request) Successful in 2m9s
Swap the native <select> in CategoryZoomHeader for the reusable
CategoryCombobox. Enhances the combobox with ARIA compliance
(combobox, listbox, option roles + aria-expanded, aria-controls,
aria-activedescendant) and hierarchy indentation based on parent_id
depth. Adds reports.category.searchPlaceholder in FR/EN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:07:47 -04:00
49a4ef2171 Merge pull request 'fix(reports/cartes): remove broken period selector + add savings-rate tooltip' (#107) from issue-101-cartes-period-savings-tooltip into main 2026-04-19 01:04:38 +00:00
le king fu
b258e2b80a fix(reports/cartes): remove broken period selector + add savings-rate tooltip (#101)
All checks were successful
PR Check / rust (push) Successful in 21m51s
PR Check / frontend (push) Successful in 2m29s
PR Check / rust (pull_request) Successful in 21m3s
PR Check / frontend (pull_request) Successful in 2m11s
- Remove the non-functional PeriodSelector from /reports/cartes — the Cartes
  report is by design a "month X vs X-1 vs X-12" snapshot, so the
  reference-month picker is the only control needed.
- Simplify useCartes to drop its useReportsPeriod dependency; the hook now
  only exposes the reference year/month and its setter.
- Add a (?) help bubble (lucide HelpCircle) next to the savings-rate KPI
  title, wired up via a new `tooltip?: string` prop on KpiCard.
- Propagate `number | null` through CartesKpi.current and buildKpi so
  savings rate is now null (rendered as "—") when reference-month income
  is 0 instead of a misleading "0 %". Use refExpenses directly for
  seasonality.referenceAmount since it is always numeric.
- Update the cartes snapshot tests to expect null for the zero-income case.
- Add FR/EN strings reports.cartes.savingsRateTooltip + CHANGELOG entries
  in both locales.
2026-04-18 20:50:18 -04:00
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
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
119 changed files with 12292 additions and 2168 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: |

1
.gitignore vendored
View file

@ -13,6 +13,7 @@ target/
# User data
data/
!src/data/
*.db
*.db-journal
*.db-wal

View file

@ -2,6 +2,87 @@
## [Non publié]
### Ajouté
- **Seed de catégories IPC pour les nouveaux profils** : les nouveaux profils sont désormais créés avec la taxonomie v1 IPC (Indice des prix à la consommation) — une hiérarchie alignée sur les catégories de Statistique Canada. Les noms des catégories du seed sont traduits dynamiquement depuis la clé i18n `categoriesSeed.*` (FR/EN), donc affichés dans la langue de l'utilisateur. Les profils existants gardent l'ancien seed v2, marqués via une nouvelle préférence `categories_schema_version` (une page de migration ultérieure offrira le passage v2→v1). Côté interne : colonne `categories.i18n_key` (nullable) ajoutée par la migration v8 (strictement additive), `src/data/categoryTaxonomyV1.json` livré comme source de vérité côté TS, les renderers `CategoryTree` et `CategoryCombobox` utilisent `name` en repli quand aucune clé de traduction n'est présente (catégories créées par l'utilisateur) (#115)
## [0.8.3] - 2026-04-19
### Ajouté
- **Rapport Cartes — Toggle Mensuel / Cumul annuel (YTD)** (`/reports/cartes`) : nouveau toggle segmenté à côté du sélecteur de mois de référence bascule les quatre cartes KPI (revenus, dépenses, solde net, taux d'épargne) entre la valeur du mois de référence (défaut inchangé) et une vue cumul annuel. En mode YTD, la valeur courante somme janvier → mois de référence, le delta MoM la compare à la fenêtre Jan → (mois 1) de la même année (null en janvier), le delta YoY la compare à Jan → mois de référence de l'année précédente, et le taux d'épargne utilise les revenus/dépenses YTD. La sparkline 13 mois, les top mouvements, la saisonnalité et l'adhésion budgétaire restent mensuels peu importe le toggle. L'info-bulle du taux d'épargne reflète maintenant le mode actif. Choix persisté dans `localStorage` (`reports-cartes-period-mode`) (#102)
- **Guide utilisateur — Section Cartes** : nouvelle section dédiée documentant les formules des quatre KPI, le toggle Mensuel/YTD, la sparkline, les top mouvements, les règles de saisonnalité et d'adhésion budgétaire, ainsi que le cas limite du taux d'épargne (« — » quand les revenus sont à zéro) (#102)
- **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101)
- **Rapport Tendances — Par catégorie** (`/reports/trends`) : nouveau toggle segmenté pour basculer le graphique d'évolution par catégorie entre les barres empilées (par défaut, inchangé) et une vue surface empilée Recharts (`<AreaChart stackId="1">`) qui montre la composition totale dans le temps. Les deux modes partagent la même palette de catégories et les mêmes patterns SVG en niveaux de gris. Le type choisi est mémorisé dans `localStorage` (`reports-trends-category-charttype`) (#105)
### Modifié
- **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `<select>` natif (#103)
- **Rapport Comparables — Réel vs réel** (`/reports/compare`) : le tableau reprend maintenant la structure riche à 8 colonnes du tableau Réel vs budget, en scindant chaque comparaison en un bloc *Mensuel* (mois de référence vs mois de comparaison) et un bloc *Cumulatif YTD* (progression jusqu'au mois de référence vs progression jusqu'à la fenêtre précédente). En mode MoM, le cumulatif précédent couvre janvier → fin du mois précédent de la même année ; en mode YoY, il couvre janvier → même mois de l'année précédente. Le graphique reste uniquement mensuel (#104)
- **Rapport Faits saillants** (`/reports/highlights`) : les tuiles mensuelles (solde du mois courant, top mouvements vs mois précédent) s'ouvrent désormais sur le **mois précédent** au lieu du mois courant, en cohérence avec les rapports Cartes et Comparables. La tuile Cumul annuel reste ancrée au 1er janvier de l'année civile en cours. Un nouveau sélecteur de mois de référence permet de faire pivoter le solde mensuel et la comparaison des top mouvements vers n'importe quel mois passé ; le choix est mémorisé dans l'URL via `?refY=YYYY&refM=MM` pour que la vue soit bookmarkable. Le panneau de faits saillants du hub suit la même valeur par défaut (#106)
### Corrigé
- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101)
- **Rapport Cartes** : le KPI taux d'épargne affiche maintenant « — » au lieu de « 0 % » lorsque le mois de référence n'a aucun revenu (une division par zéro est indéfinie, pas zéro) (#101)
- **Rapport Cartes — adhésion budgétaire** : la carte affichait systématiquement « aucune catégorie avec budget ce mois-ci » même lorsque des budgets étaient définis sur les catégories de dépenses. Cause racine : les budgets de dépenses sont stockés signés négatifs et le filtre/la comparaison utilisaient les valeurs brutes au lieu des absolus. Le nombre de catégories, les catégories dans la cible et les montants de dépassement sont maintenant tous calculés sur les valeurs absolues (#112)
## [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)

View file

@ -2,6 +2,87 @@
## [Unreleased]
### Added
- **IPC-aligned categories seed for new profiles**: brand-new profiles are now seeded with the v1 IPC (Indice des prix à la consommation) taxonomy — a structured hierarchy aligned with Statistics Canada consumer price index categories. Category labels are now translated dynamically from the `categoriesSeed.*` i18n namespace (FR/EN), so seed categories display in the user's current language. Existing profiles remain on the legacy v2 seed, marked via a new `categories_schema_version` user preference (a later migration wizard will offer the v2→v1 transition). Internally: nullable `categories.i18n_key` column added in migration v8 (additive only), `src/data/categoryTaxonomyV1.json` bundled as the TS-side source of truth, `CategoryTree` and `CategoryCombobox` renderers fall back to the raw `name` when no translation key is present (user-created rows) (#115)
## [0.8.3] - 2026-04-19
### Added
- **Cartes report — Monthly / YTD toggle** (`/reports/cartes`): new segmented toggle next to the reference-month picker flips the four KPI cards (income, expenses, net balance, savings rate) between the reference-month value (unchanged default) and a Year-to-Date cumulative view. In YTD mode, the "current" value sums January → reference month, MoM delta compares it to the same-year Jan → (refMonth 1) window (null for January), YoY delta compares it to Jan → refMonth of the previous year, and the savings rate uses the YTD income/expenses. The 13-month sparkline, top movers, seasonality and budget adherence cards remain monthly regardless of the toggle. The savings-rate tooltip now reflects the active mode. Choice persisted in `localStorage` (`reports-cartes-period-mode`) (#102)
- **User guide — Cartes section**: new dedicated section documenting the four KPI formulas, the Monthly/YTD toggle, the sparkline, top movers, seasonality and budget adherence rules, along with the savings-rate edge case ("—" when income is zero) (#102)
- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income expenses) ÷ income × 100`, computed on the reference month (#101)
- **Trends report — by category** (`/reports/trends`): new segmented toggle to switch the category-evolution chart between stacked bars (default, unchanged) and a Recharts stacked-area view (`<AreaChart stackId="1">`) that shows total composition over time. Both modes share the same category palette and SVG grayscale patterns. The chosen type is persisted in `localStorage` (`reports-trends-category-charttype`) (#105)
### Changed
- **Category zoom report** (`/reports/category`): the category picker is now a typeable, searchable combobox with accent-insensitive matching, keyboard navigation (↑/↓/Enter/Esc) and hierarchy indentation, replacing the native `<select>` (#103)
- **Compare report — Actual vs. actual** (`/reports/compare`): the table now mirrors the rich 8-column structure of the Actual vs. budget table, splitting each comparison into a *Monthly* block (reference month vs. comparison month) and a *Cumulative YTD* block (progress through the reference month vs. progress through the previous window). MoM cumulative-previous uses Jan → end-of-previous-month of the same year; YoY cumulative-previous uses Jan → same-month of the previous year. The chart remains a monthly-only view (#104)
- **Highlights report** (`/reports/highlights`): the monthly tiles (current-month balance, top movers vs. previous month) now default to the **previous calendar month** instead of the current one, matching the Cartes and Compare reports. The YTD tile stays pinned to Jan 1st of the current civil year. A new reference-month picker lets you pivot both the monthly balance and the top-movers comparison to any past month; the selection is persisted in the URL via `?refY=YYYY&refM=MM` so the view is bookmarkable. The hub highlights panel follows the same default (#106)
### Fixed
- **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101)
- **Cartes report**: savings-rate KPI now shows "—" instead of "0 %" when the reference month has no income (division by zero is undefined, not zero) (#101)
- **Cartes report — budget adherence**: the card was always saying "no budgeted categories this month" even when budgets were defined on expense categories. Root cause: expense budgets are stored signed-negative, and the filter/comparison used raw values instead of absolutes. Categories, in-target counts, and worst-overrun amounts are now all computed on absolute values (#112)
## [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)

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-04-10 — Version 0.6.7
> Document mis à jour le 2026-04-13 — Version 0.7.3
## Stack technique
@ -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)
│ │ ├── 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/ # 14 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)
@ -121,11 +121,11 @@ 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) |
@ -146,14 +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 (gated par entitlement licence) |
| `useLicense` | État de la licence et entitlements |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
## Commandes Tauri (25)
## Commandes Tauri (35)
### `fs_commands.rs` — Système de fichiers (6)
@ -178,11 +183,11 @@ 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 (6)
### `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
@ -190,10 +195,74 @@ Chaque hook encapsule la logique d'état via `useReducer` :
- `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
@ -216,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) |
@ -233,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,112 @@ 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 Cartes (`/reports/cartes`)
Un tableau de bord condensé qui résume un mois de référence en quatre KPIs, une sparkline 13 mois, les plus gros mouvements de catégories, l'adhésion budgétaire et la saisonnalité. Deux contrôles en en-tête : le **sélecteur de mois de référence** et le **toggle Mensuel / Cumul annuel (YTD)**.
#### Les 4 cartes KPI et leurs formules
- **Revenus** = somme des transactions positives
- **Dépenses** = valeur absolue de la somme des transactions négatives
- **Solde net** = `revenus dépenses`
- **Taux d'épargne** = `(revenus dépenses) ÷ revenus × 100`. Affiché comme « — » quand les revenus sont à zéro (évite la division par zéro et le « 0 % » trompeur)
Chaque carte affiche la valeur courante, deux deltas (vs mois précédent, vs l'an dernier) et une sparkline 13 mois. Le delta est vert si la variation est favorable au KPI (hausse pour Revenus, baisse pour Dépenses), rouge dans le cas contraire.
#### Toggle Mensuel / Cumul annuel (YTD)
Placé à côté du sélecteur de mois, il bascule les 4 cartes entre deux vues :
- **Mensuel** (par défaut) — la valeur courante est celle du mois de référence. Les deltas comparent ce mois à son précédent (MoM) et au même mois de l'an dernier (YoY)
- **Cumul annuel (YTD)** — la valeur courante est la somme depuis le 1er janvier de l'année de référence jusqu'à la fin du mois de référence inclus. Les deltas deviennent :
- **MoM YTD** = cumul actuel (Jan→mois de réf) vs cumul précédent (Jan→mois de réf1) de la même année. **Null en janvier** (pas de fenêtre antérieure dans la même année) — affiché comme « — »
- **YoY YTD** = cumul actuel vs le même cumul (Jan→mois de réf) de l'année précédente
- **Taux d'épargne YTD** = `(revenus YTD dépenses YTD) ÷ revenus YTD × 100`, null si les revenus YTD sont à zéro
Le choix du mode est **persisté** (clé locale `reports-cartes-period-mode`) et restauré au redémarrage. La sparkline 13 mois, elle, reste toujours mensuelle — elle donne le contexte temporel indépendamment du toggle.
#### Sparkline 13 mois
Chaque KPI inclut une mini-courbe des 13 derniers mois (mois de référence + 12 précédents). Les mois sans données comptent comme zéro pour que la courbe reste continue. Non affectée par le toggle Mensuel / YTD.
#### Top mouvements (MoM)
Deux listes : les 5 catégories avec la plus forte **hausse** de dépenses vs mois précédent et les 5 avec la plus forte **baisse**. Triées par variation absolue en dollars, avec la variation en pourcentage à droite. Toujours mensuelles, indépendamment du toggle.
#### Saisonnalité
Compare les dépenses du mois de référence à la **moyenne du même mois calendaire sur les 2 années précédentes**.
- Écart en pourcentage : `(dépenses du mois moyenne historique) ÷ moyenne historique × 100`
- Affiché comme « pas assez d'historique pour ce mois » s'il n'y a aucune donnée historique ou si la moyenne est à zéro
Toujours basée sur le mois de référence, indépendamment du toggle Mensuel / YTD.
#### Adhésion budgétaire
Score `N/M` des catégories dont les dépenses restent sous le budget mensuel (comparaison en valeur absolue pour gérer correctement les budgets de dépenses stockés signés négatifs).
- Seules les catégories de type **dépense** avec un budget non nul sont comptées, feuilles uniquement (les catégories parentes sont ignorées pour éviter le double comptage)
- Suivi des **3 pires dépassements** avec le montant et le pourcentage de dépassement
Toujours mensuelle, indépendamment du toggle.
#### À savoir
- Le sélecteur de période générique (utilisé par les autres rapports) est volontairement absent ici : la Cartes pivote autour d'un mois unique avec comparaisons automatiques, donc seul le sélecteur de mois de référence est exposé
- Le taux d'épargne affiche « — » (pas « 0 % ») quand les revenus sont à zéro, pour distinguer « pas de revenus » de « revenus = dépenses »
### 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 +365,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 +375,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 +387,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

71
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "simpl_result_scaffold",
"version": "0.6.6",
"version": "0.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl_result_scaffold",
"version": "0.6.6",
"version": "0.8.3",
"license": "GPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -1431,6 +1431,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@ -3297,10 +3357,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.3",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {

View file

@ -0,0 +1,81 @@
# Spec Decisions — Refonte du seed de catégories vers IPC Statistique Canada
> Date: 2026-04-19
> Projet: simpl-resultat
> Statut: Draft
> Slug: refonte-seed-categories-ipc
## Contexte
Le seed actuel (migration v2, `src-tauri/src/database/seed_categories.sql`) comprend 42 catégories sur 2 niveaux, structurées autour d'un axe cosmétique "récurrentes vs ponctuelles" sans logique métier derrière. Cette organisation :
- Ne reflète aucun référentiel comptable standard (pas d'alignement possible avec Statistique Canada, CPA, ou benchmarks internationaux).
- Exploite seulement 2 des 3 niveaux de hiérarchie supportés par le code (`src/services/categoryService.ts:55-60`).
- Contient des catégories "fourre-tout" (Amazon, Projets) qui cassent la cohérence comptable.
- Propose des mots-clés limités aux fournisseurs rencontrés dans un jeu de données historique personnel.
La refonte vise à s'aligner sur la **classification IPC de Statistique Canada (panier 2024)** qui regroupe les dépenses des ménages en 8 composantes principales officielles, étendues de granularité fine inspirée de Monarch Money (~60 catégories standard, 3 niveaux).
Le spike `seed-standard` (archivé sous `~/claude-code/.spikes/archived/seed-standard/`) a livré 4 documents préparatoires :
- `NOTES.md` — synthèse et décisions
- `code/seed-proposal-v1.sql` — taxonomie v1 (139 catégories, 150+ keywords)
- `code/mapping-old-to-new.md` — table de correspondance v2→v1
- `code/preview-page-mockup.md` — wireframes 3-étapes
**Bénéficiaires** : tous les utilisateurs actuels et futurs de Simpl'Résultat — en particulier les ménages québécois/canadiens qui pourront éventuellement comparer leurs dépenses aux moyennes Statistique Canada.
## Objectif
Remplacer le seed de catégories par une taxonomie alignée IPC/Monarch (3 niveaux, 9 racines IPC + Revenus/Finances/Transferts, ~140 catégories, 150+ keywords fournisseurs canadiens), livrer une page de prévisualisation read-only (Livraison 1), puis une page de migration interactive à 3 étapes avec backup obligatoire via SREF (Livraison 2). Les profils existants migrent en opt-in conscient ; les nouveaux profils reçoivent v1 automatiquement.
## Scope
### IN
- Nouveau fichier `consolidated_schema.sql` avec seed v1 IPC appliqué aux **nouveaux profils** automatiquement.
- Livraison 1 (Option E) : page read-only `/paramètres/catégories/standard` — arbre navigable de la taxonomie v1 avec descriptions, compteurs, export PDF. Aucune modification de données.
- Livraison 2 (Option B) : page de migration 3 étapes (Découvrir / Simuler / Consentir) pour les profils existants.
- Étape Simuler : dry-run complet, calcul des mappings automatiques avec badges de confiance, choix manuel pour cas ambigus.
- Étape Consentir : backup SREF obligatoire (format AES-256-GCM existant) + vérification d'intégrité AVANT toute écriture destructive + migration SQL atomique.
- Algorithme de ventillage en 4 passes (keyword → supplier → défaut → revue utilisateur) pour catégories splittées (Transport commun → Bus/Train, Assurances → 4 branches).
- Préservation des catégories custom utilisateur sous un parent "Catégories personnalisées (migration)" créé automatiquement.
- Bannière dashboard one-shot post-MAJ + entrée permanente dans Paramètres pour inviter à découvrir E et potentiellement B.
- Bouton "Rétablir la sauvegarde" accessible pendant 90 jours post-migration.
- i18n des noms de catégories seed via clés techniques (ex: `seed.categories.alimentation.epicerie`) avec fallback au nom brut pour les catégories custom.
- Couverture de tests complète : unitaires (algo mapping), intégration (flow backup→migrate→rollback), régression (transactions/budgets/keywords post-migration), QA manuelle.
### OUT (explicitement exclu)
- Attribut `transactions.frequency` (recurring/one_shot/unknown) et détection auto → issue séparée future.
- Attribut `categories.essentiality` (essential/discretionary/savings) et rapport 50/30/20 → issue séparée future.
- Peuplement de la colonne `categories.icon` (reste NULL, décision reportée à une future issue UI).
- Suppression de la colonne `categories.icon` (non nécessaire, hors scope).
- Comparaisons avec les moyennes Statistique Canada (infrastructure nécessaire pour télécharger des datasets IPC, analyser, afficher — hors scope v1).
- Migration automatique silencieuse des profils existants (principe : consentement explicite obligatoire).
- Suppression physique du fichier SREF backup (privacy-first : l'app ne gère pas les fichiers de l'utilisateur au-delà de la création).
- Traduction EN des catégories custom créées par l'utilisateur (restent dans la langue saisie).
- Version mobile / Simpl'Résultat Web (hors stack du projet aujourd'hui).
## Decisions prises
| Question | Décision | Raison |
|----------|----------|--------|
| Ordre de livraison (E read-only, B migration, ou fusion) | **E puis B en deux livraisons** | Permet de collecter du feedback sur la taxonomie via E avant d'investir dans l'UX migration B. Deux PRs testables indépendamment. |
| i18n des noms de catégories | **Clés i18n pour seed + noms libres pour custom** | Préserve l'expérience bilingue sans table de traduction BDD. Renderer : clé i18n si elle existe, sinon nom brut. |
| Nouveaux profils après MAJ | **v1 automatique** | Pas de friction pour un nouvel utilisateur sans historique. v1 devient le nouveau standard. |
| Rétention bannière "Rétablir la sauvegarde" | **90 jours** | Laisse le temps à plusieurs cycles mensuels pour détecter un problème. Le fichier SREF lui-même n'est jamais supprimé par l'app. |
| Granularité L3 (~90 feuilles) | **Toutes actives par défaut** | Simplicité, prévisibilité. L'utilisateur peut désactiver via `is_active=0` depuis les paramètres. Overhead négligeable (tree virtualisable). |
| Invitation des profils existants | **Bannière dashboard one-shot + entrée permanente dans Paramètres** | Équilibre entre découvrabilité et respect de l'utilisateur. Ni silent (adoption lente), ni modale bloquante (intrusif). |
| Catégories custom pendant la migration | **Préservées sous parent "Catégories personnalisées (migration)"** | Aucune perte de données ou de règles custom. L'utilisateur déplace à son rythme. Friction minimale à la migration. |
| Colonne `categories.icon` | **Garder NULL, décision reportée** | Ne pas élargir le scope. Issue UI séparée décidera du renderer (emojis / lucide-react / SVG). |
| Tests | **Unitaires + intégration + régression + QA manuelle** | Feature destructive sur données utilisateur → couverture complète justifiée. Algo ventillage = unitaires, migration end-to-end = intégration, non-régression = régression. |
## References
| Source | Pertinence |
|--------|------------|
| [IPC Statistique Canada — panier 2024](https://www150.statcan.gc.ca/n1/pub/62f0014m/62f0014m2025003-fra.htm) | Les 8 composantes principales officielles structurent les racines L1 v1. Référence "quasi-PCGR" pour les dépenses des ménages au Canada. |
| [Statistique Canada — Enquête sur les dépenses des ménages (EDM)](https://www23.statcan.gc.ca/imdb/p2SV_f.pl?Function=getSurvey&SDDS=3508) | Fournit la granularité L2/L3 avec >650 codes de classification détaillés. |
| [Monarch Money — Default Categories](https://help.monarch.com/hc/en-us/articles/360048883851-Default-Categories) | Benchmark 3 niveaux, ~60 catégories par défaut. Inspiration pour la granularité fine (ex: Food & Dining → Groceries, Restaurants, Coffee). |
| Spike `seed-standard` (archivé) | Analyse complète du modèle de données, taxonomie v1 draftée, mapping v2→v1 à 88% automatisable, wireframes 3-étapes. Input direct pour Phase 3b. |
| `src-tauri/src/commands/export_import_commands.rs:8-49` | Format SREF v0.1 + AES-256-GCM + Argon2id réutilisable tel quel pour le backup pre-migration. |
| `src/services/dataExportService.ts:7-10, 199` | Mode `transactions_with_categories` + `importCategoriesOnly()` pour le flow backup/restore. |
| `src/services/categoryService.ts:55-60, 68-74, 175-186` | Règles `is_inputable` auto-gérées + limite 3 niveaux enforced. Le nouveau seed doit respecter ces invariants. |

View file

@ -0,0 +1,375 @@
# Spec Plan — Refonte du seed de catégories vers IPC Statistique Canada
> Date: 2026-04-19
> Projet: simpl-resultat
> Statut: Draft
> Slug: refonte-seed-categories-ipc
> Decisions: [spec-decisions-refonte-seed-categories-ipc.md](./spec-decisions-refonte-seed-categories-ipc.md)
## Design
### UX / Interface
#### Livraison 1 — Découverte (Option E)
**Bannière dashboard** (première ouverture post-MAJ)
- Position : haut du dashboard, dismissable
- Texte : "Découvrez la nouvelle structure standard des catégories — inspirée de Statistique Canada"
- CTA : *Voir le guide* → navigue vers `/paramètres/categories/standard`
- Une fois dismiss, ne réapparaît plus (flag `user_preferences.categories_v1_banner_dismissed = true`)
**Page `/paramètres/categories/standard`** (lecture seule)
- Bloc pédagogique en haut : pourquoi cette structure, lien vers source Statistique Canada
- Arbre navigable avec expand/collapse des branches
- Hover sur catégorie : tooltip avec description + exemples de fournisseurs (ex: "Metro, IGA, Maxi, Loblaws...")
- Compteur global : "9 catégories racines, ~40 sous-catégories, ~90 feuilles"
- Bouton recherche full-text
- Bouton export PDF
- **Aucune action destructive** — lecture pure
**Entrée Paramètres**
- Dans `Paramètres > Gestion des catégories`, ajouter lien *Explorer la structure standard*
#### Livraison 2 — Migration (Option B)
Page 3 étapes séquentielles à `/paramètres/categories/migrer` :
**Étape 1 — Découvrir** (reprend la page de Livraison 1 en lecture)
- CTA : *Continuer vers l'aperçu de migration*
**Étape 2 — Simuler** (dry-run)
- Résumé impact : X catégories, Y transactions, Z règles, W budgets
- Table 3 colonnes : *Actuelle* | *Correspondance* | *v1 proposée*
- Badges confiance 🟢/🟡/🟠/🔴
- Panneau latéral cliquable par ligne : liste des transactions affectées + possibilité de changer la cible
- Compteur "N décisions à prendre" + bouton suivant désactivé tant que toutes les 🟠 ne sont pas résolues
- Les choix sont persistés en mémoire (pas encore BDD)
**Étape 3 — Consentir**
- Checklist explicite : "Je comprends que cette opération modifie mes catégories / Une sauvegarde sera créée avant tout changement / Je peux rétablir à tout moment"
- Bouton *Créer la sauvegarde et migrer* désactivé tant que checklist non cochée
- Pendant exécution : loader avec 4 étapes affichées (backup créé → vérifié → migration SQL → commit)
- Écran succès avec chemin du fichier SREF + CTA *Aller au tableau de bord*
- Écran échec backup : abort complet, aucune écriture, message clair + options (changer dossier / réessayer / annuler)
**Bannière post-migration** (Paramètres > Catégories, 90 jours)
- "Migration appliquée le <date>. Sauvegarde : <chemin>"
- Bouton *Rétablir la sauvegarde* → modale confirmation → `importFullProfile()` en mode replace
- Bouton *Ne plus afficher* → flag dans `user_preferences.categories_migration_banner_dismissed`
### Données
#### Nouvelle migration SQL v8 (additive)
Nom : `v8__category_schema_version.sql`
Ajoute :
- Colonne `categories.i18n_key TEXT NULL` — clé i18n technique pour les catégories seedées (ex: `seed.categories.alimentation.epicerie`). NULL pour les catégories custom → le renderer utilise `name` brut.
- Entrée dans `user_preferences` : `categories_schema_version` = `'v1'` ou `'v2'` (détermine quelle taxonomie le profil utilise).
**Important** : cette migration **n'écrase pas** le seed v2 des profils existants. Elle ajoute juste la colonne `i18n_key` (NULL par défaut) et pose `categories_schema_version='v2'` pour tous les profils existants.
#### `consolidated_schema.sql` mis à jour
- Contient le seed v1 complet (issu de `spike-archived/seed-standard/code/seed-proposal-v1.sql`)
- Pose `categories_schema_version='v1'` par défaut
- Les i18n_key sont peuplées au seed
#### Clés i18n ajoutées
Dans `src/i18n/locales/fr.json` et `en.json`, nouveau namespace `categoriesSeed` :
```json
{
"categoriesSeed": {
"revenus": { "root": "Revenus", "emploi": { "root": "Emploi", "paie": "Paie régulière", ... } },
"alimentation": { "root": "Alimentation", "epicerie": { "root": "Épicerie & marché", "reguliere": "Épicerie régulière", ... } },
...
}
}
```
Les `i18n_key` dans la BDD pointent vers ces clés : ex `categoriesSeed.alimentation.epicerie.reguliere`.
#### Rien de neuf en schéma v2 : pas de colonnes `frequency` ni `essentiality`
Ces attributs sont hors scope (cf. spec-decisions).
### Architecture
#### Composants React (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/pages/CategoriesStandardGuidePage.tsx` | Page read-only de Livraison 1 |
| `src/pages/CategoriesMigrationPage.tsx` | Page 3-étapes de Livraison 2 (routeur interne par étape) |
| `src/components/categories-migration/StepDiscover.tsx` | Étape 1 |
| `src/components/categories-migration/StepSimulate.tsx` | Étape 2 avec table 3 colonnes |
| `src/components/categories-migration/StepConsent.tsx` | Étape 3 avec checklist et loader |
| `src/components/categories-migration/MappingRow.tsx` | Ligne de table avec badge confiance + panneau latéral |
| `src/components/categories-migration/TransactionPreviewPanel.tsx` | Panneau latéral montrant transactions impactées |
| `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx` | Bannière dashboard one-shot |
| `src/components/settings/CategoriesMigrationBackupBanner.tsx` | Bannière post-migration (90j) |
#### Services (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/services/categoryTaxonomyService.ts` | Source de vérité de la taxonomie v1 (structure hardcodée en TS, utilisée par StepDiscover + StepSimulate pour afficher l'arbre cible). Import depuis JSON bundle. |
| `src/services/categoryMappingService.ts` | Calcule le mapping v2→v1 avec badge de confiance. Implémente l'algo 4-passes (keyword → supplier → défaut → revue). Retourne une structure `MigrationPlan` en mémoire, sans écriture BDD. |
| `src/services/categoryMigrationService.ts` | Orchestre la migration : prend un `MigrationPlan` + `BackupResult` validé, exécute la transaction SQL atomique (INSERT catégories v1 → UPDATE transactions/budgets/keywords → DELETE catégories v2 non mappées → création parent "Catégories personnalisées (migration)" si custom détectées). |
| `src/services/categoryBackupService.ts` | Wrapper autour de `dataExportService` pour le flow pre-migration : crée un fichier SREF nommé `<ProfileName>_avant-migration-<ISO8601>.sref` dans `~/Documents/Simpl-Resultat/backups/`, vérifie l'intégrité (read-back + checksum), retourne `BackupResult` ou lève une erreur claire. |
#### Hooks (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/hooks/useCategoryTaxonomy.ts` | Charge la taxonomie v1 depuis le service (useMemo). |
| `src/hooks/useCategoryMigration.ts` | useReducer pour l'état de la page de migration (étape courante, mapping plan, backup result, erreurs). |
#### Fichier JSON de taxonomie
`src/data/categoryTaxonomyV1.json` — structure hiérarchique de la taxonomie v1 utilisée par `categoryTaxonomyService.ts`. Régénéré depuis `spec-plan-*/code/seed-proposal-v1.sql` (source de vérité = le SQL, le JSON est dérivé pour l'UI).
#### Flow d'intégration
```
Utilisateur clique "Créer la sauvegarde et migrer"
useCategoryMigration dispatches START_MIGRATION
categoryBackupService.createAndVerify()
→ Tauri: pick_save_file + write_export_file + read_import_file
→ Si échec : dispatch BACKUP_FAILED, abort, aucune écriture BDD
→ Si succès : dispatch BACKUP_VERIFIED, retourne BackupResult
categoryMigrationService.applyMigration(plan, backup)
→ BEGIN TRANSACTION
→ INSERT catégories v1 (IDs 1000+, i18n_key peuplées, is_inputable calculé)
→ UPDATE transactions SET category_id = <mapping[old_id]>
→ UPDATE budgets SET category_id = <mapping[old_id]>
→ UPDATE keywords SET category_id = <mapping[old_id]>
→ Si custom détectées : INSERT parent "Catégories personnalisées (migration)" + re-parent
→ DELETE FROM categories WHERE id IN (<v2 non mappées, non custom>)
→ UPDATE user_preferences SET value='v1' WHERE key='categories_schema_version'
→ INSERT INTO user_preferences (key='last_categories_migration', value=<JSON métadonnées>)
→ COMMIT
→ Si erreur : ROLLBACK, backup reste disponible pour rétablissement
dispatch MIGRATION_SUCCESS, affiche écran succès
```
## Plan de travail
### Issue 1 — Seed v1 + i18n keys pour nouveaux profils [type:task]
Dépendances : aucune
- [ ] Ajouter migration SQL v8 : colonne `categories.i18n_key TEXT NULL` + `user_preferences('categories_schema_version', 'v2')` pour profils existants
- [ ] Mettre à jour `consolidated_schema.sql` avec le seed v1 complet (issu de `spike-archived/seed-standard/code/seed-proposal-v1.sql`) et poser `categories_schema_version='v1'` par défaut
- [ ] Créer `src/data/categoryTaxonomyV1.json` dérivé du SQL seed v1
- [ ] Ajouter les clés i18n FR et EN dans `src/i18n/locales/{fr,en}.json` sous `categoriesSeed.*`
- [ ] Adapter le renderer CategoryTree/CategoryCombobox pour utiliser `i18n_key` si présent, fallback sur `name`
- [ ] Tests : création d'un nouveau profil → vérifier que le seed v1 est appliqué, que `categories_schema_version='v1'`, et que les noms s'affichent traduits FR/EN
### Issue 2 — Service categoryTaxonomyService (source taxonomie v1 en TS) [type:task]
Dépendances : Issue 1
- [ ] Créer `src/services/categoryTaxonomyService.ts` avec `getTaxonomyV1()` retournant l'arbre typé depuis le JSON
- [ ] Créer `src/hooks/useCategoryTaxonomy.ts`
- [ ] Exposer des helpers : `findByPath(path)`, `getLeaves()`, `getParentById(id)`
### Issue 3 — Page "Guide des catégories standard" (Livraison 1) [type:feature]
Dépendances : Issue 2
- [ ] Créer route `/paramètres/categories/standard` dans `src/App.tsx`
- [ ] Créer `src/pages/CategoriesStandardGuidePage.tsx`
- [ ] Implémenter l'arbre navigable avec expand/collapse, tooltips, compteur global
- [ ] Bouton recherche full-text
- [ ] Bouton export PDF (via `window.print()` avec feuille style dédiée, ou lib PDF léger)
- [ ] Ajouter lien dans `src/components/settings/CategoriesCard.tsx` (ou équivalent)
### Issue 4 — Bannière dashboard one-shot + découverte [type:feature]
Dépendances : Issue 3
- [ ] Créer `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx`
- [ ] Ajouter flag `categories_v1_banner_dismissed` dans `user_preferences`
- [ ] Intégrer au `Dashboard.tsx` : affichée si `categories_schema_version='v2'` AND flag non-dismiss
- [ ] CTA dismissable vers la page Guide
### Issue 5 — Service categoryMappingService (algo ventillage 4 passes) [type:task]
Dépendances : Issue 2
- [ ] Créer `src/services/categoryMappingService.ts`
- [ ] Implémenter l'algo 4 passes (keyword → supplier → défaut → revue)
- [ ] Types : `MigrationPlan`, `MappingRow`, `ConfidenceBadge`
- [ ] Fonction `computeMigrationPlan(profileData): MigrationPlan` — pure, sans effet de bord BDD
- [ ] Mapping table encodée depuis `spike-archived/seed-standard/code/mapping-old-to-new.md`
- [ ] Détection des catégories custom (non présentes dans le seed v2)
### Issue 6 — Service categoryBackupService + wrapper SREF pre-migration [type:task]
Dépendances : aucune (peut aller en parallèle avec Issue 5)
- [ ] Créer `src/services/categoryBackupService.ts`
- [ ] Fonction `createPreMigrationBackup(profile): Promise<BackupResult>` :
- Génère nom fichier `<ProfileName>_avant-migration-<ISO8601>.sref`
- Emplacement par défaut `~/Documents/Simpl-Resultat/backups/`
- Appelle `dataExportService.performExport('transactions_with_categories', 'json', password)`
- Écrit via `write_export_file` (commande Tauri existante)
- Vérifie intégrité via `read_import_file` + checksum SHA-256
- Retourne `BackupResult { path, size, checksum, verifiedAt }` ou throw
- [ ] Gérer erreurs : espace disque, permissions, chiffrement si profil a un PIN
### Issue 7 — Page de migration 3-étapes (Livraison 2) [type:feature]
Dépendances : Issue 5, Issue 6
- [ ] Créer route `/paramètres/categories/migrer`
- [ ] Créer `src/pages/CategoriesMigrationPage.tsx` avec routeur interne (step 1/2/3)
- [ ] Créer `src/components/categories-migration/` avec StepDiscover, StepSimulate, StepConsent, MappingRow, TransactionPreviewPanel
- [ ] Créer `src/hooks/useCategoryMigration.ts` (useReducer)
- [ ] Créer `src/services/categoryMigrationService.ts` avec `applyMigration(plan, backup)` :
- Transaction SQL atomique (BEGIN/COMMIT/ROLLBACK)
- INSERT v1 + UPDATE transactions/budgets/keywords + création parent custom + DELETE v2 non mappées
- Journal dans `user_preferences.last_categories_migration`
- [ ] Écran succès/échec avec chemin backup et options de rollback
### Issue 8 — Bouton "Rétablir la sauvegarde" (90 jours) [type:feature]
Dépendances : Issue 6, Issue 7
- [ ] Créer `src/components/settings/CategoriesMigrationBackupBanner.tsx`
- [ ] Afficher dans `Paramètres > Catégories` si `last_categories_migration.timestamp` < 90 jours et flag `banner_dismissed=false`
- [ ] Modale de confirmation
- [ ] Appel à `dataImportService.importFullProfile(path, { mode: 'replace' })`
- [ ] Post-rollback : mettre à jour `categories_schema_version='v2'` et `last_categories_migration.reverted_at`
### Issue 9 — Tests complets [type:test]
Dépendances : Issues 1, 2, 5, 6, 7
- [ ] Tests unitaires `categoryMappingService` (algo 4 passes, badges confiance, détection custom)
- [ ] Tests unitaires `categoryBackupService` (création, vérification, erreurs)
- [ ] Tests intégration : flow complet `plan → backup → migrate → verify → rollback`
- [ ] Tests régression : transactions/budgets/keywords préservés post-migration avec IDs remappés
- [ ] Tests régression : fixtures paramétrées (ancien seed v2 ET nouveau seed v1) sur budget, transactions, splits, auto-catégorisation
- [ ] QA manuelle : checklist dans `docs/qa-refonte-seed-categories-ipc.md` couvrant les 3 étapes UI, les cas nominal/échec/rollback
### Ordre d'exécution
```
Livraison 1 (E read-only + seed nouveaux profils):
Issue 1 → Issue 2 → Issue 3 → Issue 4
Livraison 2 (B migration profils existants):
Issue 2 → Issue 5 ─┐
├→ Issue 7 → Issue 8
Issue 6 ───────────┘
Tests:
Issues 1,2,5,6,7 → Issue 9
```
Livraison 1 = Issues 1-4 (PR #1). Livraison 2 = Issues 5-9 (PR #2 ou series).
## Fichiers concernés
| Fichier | Action | Raison |
|---------|--------|--------|
| `src-tauri/src/lib.rs` | Modifier | Ajouter migration v8 (colonne `i18n_key` + pref `categories_schema_version`) |
| `src-tauri/src/database/migrations/v8__category_schema_version.sql` | Créer | Migration additive |
| `src-tauri/src/database/consolidated_schema.sql` | Modifier | Seed v1 complet pour nouveaux profils |
| `src/i18n/locales/fr.json` | Modifier | Nouveau namespace `categoriesSeed` + clés UI migration |
| `src/i18n/locales/en.json` | Modifier | Nouveau namespace `categoriesSeed` + clés UI migration |
| `src/data/categoryTaxonomyV1.json` | Créer | Dérivé du SQL seed v1 |
| `src/services/categoryTaxonomyService.ts` | Créer | Source taxonomie v1 côté TS |
| `src/services/categoryMappingService.ts` | Créer | Algo 4 passes |
| `src/services/categoryBackupService.ts` | Créer | Wrapper SREF pre-migration |
| `src/services/categoryMigrationService.ts` | Créer | Orchestration migration SQL atomique |
| `src/hooks/useCategoryTaxonomy.ts` | Créer | |
| `src/hooks/useCategoryMigration.ts` | Créer | useReducer état page migration |
| `src/pages/CategoriesStandardGuidePage.tsx` | Créer | Livraison 1 |
| `src/pages/CategoriesMigrationPage.tsx` | Créer | Livraison 2 |
| `src/components/categories-migration/*` | Créer | 5 composants (step 1/2/3 + mapping row + preview panel) |
| `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx` | Créer | Bannière one-shot |
| `src/components/settings/CategoriesMigrationBackupBanner.tsx` | Créer | Bannière post-migration 90j |
| `src/components/settings/CategoriesCard.tsx` | Modifier | Ajouter lien vers page Guide + page Migrer |
| `src/pages/Dashboard.tsx` | Modifier | Intégrer la bannière découverte |
| `src/App.tsx` | Modifier | Nouvelles routes |
| `src/components/categories/CategoryTree.tsx` | Modifier | Support `i18n_key` fallback `name` |
| `src/components/categories/CategoryCombobox.tsx` | Modifier | Idem |
| `docs/architecture.md` | Modifier | Documenter nouveaux services, pages, migration v8 |
| `docs/adr/NNNN-refonte-seed-categories-ipc.md` | Créer | ADR pour le choix IPC + pattern prévisualisation-consentement |
| `docs/qa-refonte-seed-categories-ipc.md` | Créer | Checklist QA manuelle |
| `CHANGELOG.md` | Modifier | Entrée sous `[Unreleased]` — Added/Changed |
| `CHANGELOG.fr.md` | Modifier | Idem FR |
## Plan de tests
### Tests unitaires
- `categoryMappingService.computeMigrationPlan()` : chaque règle de mapping v2→v1 (18 haute / 12 moyenne / 3 basse / 1 aucune) retourne le bon badge et la bonne cible.
- Algo 4 passes :
- Pass 1 (keyword match) avec diverses combinaisons
- Pass 2 (supplier propagation)
- Pass 3 (fallback défaut)
- Pass 4 (flag "à réviser")
- Détection des catégories custom (absentes du seed v2) → bucket `preserved`.
- Détection des splits (ex: Transport en commun 28 v2 → Bus 1521 OR Train 1522 v1).
- `categoryBackupService.createPreMigrationBackup()` avec mocks Tauri :
- Succès normal : retourne BackupResult valide
- Échec write_export_file : throw erreur "Impossible de créer la sauvegarde"
- Échec integrity check : throw erreur "Sauvegarde corrompue"
- Profil avec PIN : chiffrement appliqué
### Tests d'intégration
- Flow complet `plan → backup → migrate → verify` sur profil fixture v2 réaliste :
- Catégories v2 mappées correctement
- Transactions re-liées aux nouvelles catégories v1
- Keywords re-liés
- Budgets re-liés
- Catégories custom regroupées sous "Catégories personnalisées (migration)"
- Flow `rollback` après migration : import SREF restaure l'état v2 exact (transactions, keywords, budgets, categories).
- Échec backup → abort → aucune écriture BDD (profil v2 intact).
- Échec migration SQL → ROLLBACK → profil v2 intact, backup reste disponible.
### Tests de régression
Fixtures paramétrées v2 ET v1 pour couvrir :
- Auto-catégorisation (`categorizationService.applyKeywordToTransaction`)
- Budgets mensuels et agrégation parent/enfant (`budgetService.getBudgetVsActual`)
- Splits de transactions sur catégories multiples (`transactionService.splitTransaction`)
- Import CSV avec matching supplier/keyword
- Export/Import SREF (pas de régression sur le format)
- UI : `CategoryTree` et `CategoryCombobox` rendent correctement v2 et v1
## Critères d'acceptation
### Livraison 1
- [ ] Tout nouveau profil créé après la MAJ a le seed v1 appliqué (vérifié par SELECT sur la BDD d'un fresh profile).
- [ ] La bannière dashboard s'affiche sur les profils v2 existants au premier lancement post-MAJ.
- [ ] La bannière disparaît après dismiss et ne réapparaît plus (persistant).
- [ ] La page `/paramètres/categories/standard` affiche correctement l'arbre complet v1 avec FR/EN.
- [ ] Recherche full-text trouve les catégories par nom ou par mot-clé associé.
- [ ] Export PDF produit un document lisible de la taxonomie.
- [ ] Aucun changement aux données des profils v2 existants (test : avant/après MAJ, `SELECT * FROM categories` identique).
### Livraison 2
- [ ] Page `/paramètres/categories/migrer` est accessible depuis la page Guide et depuis Paramètres.
- [ ] Étape 2 affiche le bon badge de confiance pour chaque catégorie (validation sur fixture).
- [ ] Toutes les catégories 🟠 "split requis" bloquent l'avancement tant que non résolues.
- [ ] Backup SREF est créé et vérifié AVANT toute écriture BDD.
- [ ] Échec backup → abort → aucune écriture BDD (profil v2 intact).
- [ ] Migration succès → transactions, budgets, keywords tous re-liés correctement.
- [ ] Catégories custom préservées sous "Catégories personnalisées (migration)".
- [ ] Bannière post-migration visible pendant 90 jours, dismissable.
- [ ] Bouton *Rétablir la sauvegarde* fonctionne : restaure exactement l'état v2.
### Global
- [ ] Tous les tests unitaires et intégration passent (`npm test`, `cargo test`).
- [ ] Type-check clean (`npm run build`).
- [ ] CHANGELOG mis à jour FR et EN.
- [ ] `docs/architecture.md` mis à jour.
- [ ] ADR rédigé.
- [ ] QA manuelle exécutée selon checklist `docs/qa-refonte-seed-categories-ipc.md`.
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| Profil v2 avec 0 catégorie custom → mapping simple | Testé par fixture minimale |
| Profil v2 avec ≥50 catégories custom (utilisateur power) | UI pagine la liste dans l'étape 2 ; parent "Catégories personnalisées (migration)" absorbe tout |
| Utilisateur abandonne étape 2 en plein milieu | Aucune écriture BDD, le plan en mémoire est perdu — OK, aucun effet secondaire |
| Utilisateur abandonne étape 3 après checklist cochée mais avant backup | Bouton *Annuler* abort propre, aucune écriture |
| Espace disque insuffisant pour backup | `categoryBackupService` lève erreur claire → UI montre écran d'échec avec options "changer dossier / réessayer / annuler" |
| Migration SQL échoue au milieu | `ROLLBACK` automatique, backup reste disponible, UI affiche erreur + invite à rétablir |
| Utilisateur lance 2 instances de Simpl'Résultat en parallèle pendant la migration | SQLite lock naturel ; la 2e instance attend ; bas risque (app desktop mono-fenêtre en pratique) |
| Profil protégé par PIN : backup doit être chiffré | `categoryBackupService` récupère le PIN depuis le ProfileContext et passe en password à `write_export_file` |
| Utilisateur renomme/déplace le fichier SREF après migration | Le bouton *Rétablir* affiche un file picker si le chemin enregistré n'est plus valide |
| Déjà-migré : utilisateur re-lance la page de migration | Détection `categories_schema_version='v1'` → message "Votre profil utilise déjà la taxonomie v1" avec option "revoir la sauvegarde" uniquement |
| Utilisateur veut migrer APRÈS les 90 jours (bannière disparue) | Entrée permanente dans Paramètres > Catégories reste disponible → bouton *Explorer / Migrer* |
| Clé i18n manquante (typo dans le JSON) | Fallback sur le nom brut de la catégorie — pas de crash |
| Seed v1 manque une feuille qu'un utilisateur a en v2 (ex: "Projets") | Mapping badge 🔴 + prompt obligatoire étape 2, ou préservé en custom |
| Compatibilité forward : une future v2 du seed (refinement v1.1, v1.2) | `categories_schema_version` permet de détecter et ajouter des colonnes plus tard. Pattern réutilisable. |
| Performance : 139 catégories + 100+ keywords au seed pour un nouveau profil | < 200 ms sur SSD moderne, négligeable |
| Performance : migration de 5000 transactions | Transaction unique, < 2 s sur SSD moderne, loader visible pendant ce temps |

311
src-tauri/Cargo.lock generated
View file

@ -347,6 +347,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@ -484,6 +493,15 @@ dependencies = [
"toml 0.9.11+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.55"
@ -527,6 +545,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.43"
@ -842,6 +866,35 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "dbus"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
"num",
"once_cell",
"sha2",
"zeroize",
]
[[package]]
name = "der"
version = "0.7.10"
@ -2176,6 +2229,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@ -2323,6 +2377,20 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"secret-service",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@ -2374,6 +2442,15 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.7.4"
@ -2619,12 +2696,39 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nodrop"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@ -2651,6 +2755,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.0"
@ -2677,6 +2790,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -4003,6 +4127,25 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
"num",
"once_cell",
"rand 0.8.5",
"serde",
"sha2",
"zbus 4.4.0",
]
[[package]]
name = "security-framework"
version = "3.5.1"
@ -4280,15 +4423,17 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simpl-result"
version = "0.6.7"
version = "0.8.3"
dependencies = [
"aes-gcm",
"argon2",
"base64 0.22.1",
"ed25519-dalek",
"encoding_rs",
"hmac",
"hostname",
"jsonwebtoken",
"keyring",
"libsqlite3-sys",
"machine-uid",
"rand 0.8.5",
@ -4297,17 +4442,20 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"subtle",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-sql",
"tauri-plugin-updater",
"tokio",
"urlencoding",
"walkdir",
"zeroize",
]
[[package]]
@ -4628,6 +4776,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.8.9"
@ -5042,7 +5196,7 @@ dependencies = [
"thiserror 2.0.18",
"url",
"windows",
"zbus",
"zbus 5.13.2",
]
[[package]]
@ -5055,6 +5209,22 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus 5.13.2",
]
[[package]]
name = "tauri-plugin-sql"
version = "2.3.2"
@ -6680,6 +6850,16 @@ dependencies = [
"rustix",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "yoke"
version = "0.8.1"
@ -6703,6 +6883,38 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-process",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix",
"ordered-stream",
"rand 0.8.5",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.13.2"
@ -6733,9 +6945,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.14",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.13.2",
"zbus_names 4.3.1",
"zvariant 5.9.2",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils 2.1.0",
]
[[package]]
@ -6748,9 +6973,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.3.1",
"zvariant 5.9.2",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@ -6761,7 +6997,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow 0.7.14",
"zvariant",
"zvariant 5.9.2",
]
[[package]]
@ -6810,6 +7046,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "zerotrie"
@ -6862,6 +7112,19 @@ version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.9.2"
@ -6872,8 +7135,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 0.7.14",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.9.2",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils 2.1.0",
]
[[package]]
@ -6886,7 +7162,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]

View file

@ -1,6 +1,6 @@
[package]
name = "simpl-result"
version = "0.6.7"
version = "0.8.3"
description = "Personal finance management app"
license = "GPL-3.0-only"
authors = ["you"]
@ -26,6 +26,7 @@ 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"] }
@ -35,6 +36,7 @@ encoding_rs = "0.8"
walkdir = "2"
aes-gcm = "0.10"
argon2 = "0.5"
subtle = "2"
rand = "0.8"
jsonwebtoken = "9"
machine-uid = "0.5"
@ -43,6 +45,14 @@ 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

@ -1,25 +1,29 @@
// OAuth2 PKCE flow for Compte Maximus (Logto) integration.
//
// Architecture:
// - The desktop app is registered as a "Native App" in Logto (public client, no secret).
// - 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 stored as files in app_data_dir/auth/ (encrypted at rest in a future
// iteration via OS keychain). For now, plain JSON — acceptable because:
// (a) the app data dir has user-only permissions,
// (b) the access token is short-lived (1h default in Logto),
// (c) the refresh token is rotated on each use.
// - 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.
// 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::io::Write;
use std::path::{Path, PathBuf};
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")
@ -28,13 +32,10 @@ fn logto_endpoint() -> String {
// Logto app ID for the desktop native app.
fn logto_app_id() -> String {
std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "simpl-resultat-desktop".to_string())
std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "sr-desktop-native".to_string())
}
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
const AUTH_DIR: &str = "auth";
const TOKENS_FILE: &str = "tokens.json";
const ACCOUNT_FILE: &str = "account.json";
const LAST_CHECK_FILE: &str = "last_check";
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
@ -52,50 +53,6 @@ pub struct AccountInfo {
pub subscription_status: Option<String>,
}
/// Stored tokens (written to auth/tokens.json).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredTokens {
access_token: String,
refresh_token: Option<String>,
id_token: Option<String>,
expires_at: i64,
}
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 restricted permissions (0600 on Unix) for sensitive data like tokens.
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))?;
}
#[cfg(not(unix))]
{
fs::write(path, contents)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
}
Ok(())
}
fn generate_pkce() -> (String, String) {
use rand::Rng;
let mut rng = rand::thread_rng();
@ -189,25 +146,22 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let expires_at = chrono_now() + expires_in;
// Store tokens
// Persist tokens through token_store (prefers keychain over file).
let tokens = StoredTokens {
access_token: access_token.clone(),
refresh_token,
id_token,
expires_at,
};
let dir = auth_dir(&app)?;
let tokens_json =
serde_json::to_string_pretty(&tokens).map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&dir.join(TOKENS_FILE), &tokens_json)?;
token_store::save(&app, &tokens)?;
// Fetch user info
let account = fetch_userinfo(&endpoint, &access_token).await?;
// Store account info
let account_json =
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?;
// 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)
}
@ -215,16 +169,7 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result
/// Refresh the access token using the stored refresh token.
#[tauri::command]
pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, String> {
let dir = auth_dir(&app)?;
let tokens_path = dir.join(TOKENS_FILE);
if !tokens_path.exists() {
return Err("Not authenticated".to_string());
}
let tokens_raw =
fs::read_to_string(&tokens_path).map_err(|e| format!("Cannot read tokens: {}", e))?;
let tokens: StoredTokens =
serde_json::from_str(&tokens_raw).map_err(|e| format!("Invalid tokens file: {}", e))?;
let tokens = token_store::load(&app)?.ok_or_else(|| "Not authenticated".to_string())?;
let refresh_token = tokens
.refresh_token
@ -247,9 +192,9 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
.map_err(|e| format!("Token refresh failed: {}", e))?;
if !resp.status().is_success() {
// Clear stored tokens on refresh failure
let _ = fs::remove_file(&tokens_path);
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
// 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());
}
@ -272,38 +217,30 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
expires_at: chrono_now() + expires_in,
};
let tokens_json = serde_json::to_string_pretty(&new_tokens)
.map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&tokens_path, &tokens_json)?;
token_store::save(&app, &new_tokens)?;
let account = fetch_userinfo(&endpoint, &new_access).await?;
let account_json =
serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?;
account_cache::save(&app, &account)?;
Ok(account)
}
/// Read cached account info without network call.
/// 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> {
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))?;
let account: AccountInfo =
serde_json::from_str(&raw).map_err(|e| format!("Invalid account file: {}", e))?;
Ok(Some(account))
account_cache::load_unverified(&app)
}
/// Log out: clear all stored tokens and account info.
/// 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> {
let dir = auth_dir(&app)?;
let _ = fs::remove_file(dir.join(TOKENS_FILE));
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
token_store::delete(&app)?;
account_cache::delete(&app)?;
Ok(())
}
@ -314,13 +251,14 @@ pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
pub async fn check_subscription_status(
app: tauri::AppHandle,
) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(&app)?;
// Not authenticated — nothing to check
if !dir.join(TOKENS_FILE).exists() {
// 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();
@ -383,10 +321,3 @@ async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result<AccountInf
.map(|s| s.to_string()),
})
}
fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}

View file

@ -0,0 +1,48 @@
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
/// Subdirectory under the user's Documents folder where pre-migration backups
/// are written by default. Keeping the location predictable makes it easy for
/// users to find their backup files even if the app is uninstalled.
const BACKUP_SUBDIR: &str = "Simpl-Resultat/backups";
fn resolve_backup_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let documents = app
.path()
.document_dir()
.map_err(|e| format!("Cannot resolve Documents directory: {}", e))?;
Ok(documents.join(BACKUP_SUBDIR))
}
/// Resolve `~/Documents/Simpl-Resultat/backups/` and create it if missing.
/// Returns the absolute path as a string. Used by the pre-migration backup
/// flow to place SREF files in a predictable, user-visible location.
#[tauri::command]
pub fn ensure_backup_dir(app: tauri::AppHandle) -> Result<String, String> {
let dir = resolve_backup_dir(&app)?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| {
// Surface permission issues explicitly — the TS layer maps this to
// a user-facing i18n key.
if e.kind() == std::io::ErrorKind::PermissionDenied {
format!("permission_denied: {}", dir.to_string_lossy())
} else {
format!("create_dir_failed: {}: {}", dir.to_string_lossy(), e)
}
})?;
}
Ok(dir.to_string_lossy().to_string())
}
/// Return the size of a file on disk in bytes. Used to report the size of a
/// freshly-written backup to the UI. Returns a clear error if the file does
/// not exist or cannot be read.
#[tauri::command]
pub fn get_file_size(file_path: String) -> Result<u64, String> {
let metadata =
fs::metadata(&file_path).map_err(|e| format!("Cannot stat file {}: {}", file_path, e))?;
Ok(metadata.len())
}

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

@ -267,16 +267,16 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
info.edition
}
/// Read the cached account.json to check for an active Premium subscription.
/// Returns None if no account file exists or the file is invalid.
/// 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 dir = app_data_dir(app).ok()?.join("auth");
let account_path = dir.join("account.json");
if !account_path.exists() {
return None;
}
let raw = fs::read_to_string(&account_path).ok()?;
let account: super::auth_commands::AccountInfo = serde_json::from_str(&raw).ok()?;
let account = super::account_cache::load_verified(app).ok().flatten()?;
match account.subscription_status.as_deref() {
Some("active") => Some(EDITION_PREMIUM.to_string()),
_ => None,

View file

@ -1,13 +1,20 @@
pub mod account_cache;
pub mod auth_commands;
pub mod backup_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 backup_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;
@ -112,50 +114,112 @@ pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(
#[tauri::command]
pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
Ok(vec![
database::CONSOLIDATED_SCHEMA.to_string(),
database::SEED_CATEGORIES.to_string(),
])
// Brand-new profiles ship with the v1 IPC-aligned category taxonomy: the
// consolidated schema bakes the v1 seed (categories + keywords + the
// categories_schema_version='v1' preference) directly. The legacy v2 seed
// migration still runs first because tauri-plugin-sql applies every
// declared migration on `Database.load`; the consolidated script then
// deletes the v2 rows and re-inserts the v1 rows in the 1000+ id range.
Ok(vec![database::CONSOLIDATED_SCHEMA.to_string()])
}
// 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;
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 hash_pin(pin: String) -> Result<String, String> {
let mut salt = [0u8; 16];
let mut salt = [0u8; ARGON2_SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let salt_hex = hex_encode(&salt);
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);
let hash = argon2_hash(&pin, &salt)?;
let hash_hex = hex_encode(&hash);
// Store as "salt:hash"
Ok(format!("{}:{}", salt_hex, hash_hex))
// 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<bool, String> {
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 +281,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 @@
-- Consolidated schema for new profile databases
-- This file bakes in the base schema + all migrations (v3-v6)
-- Used ONLY for initializing new profile databases (not for the default profile)
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
-- use the new standard. Existing profiles keep their v2 seed and are only
-- tagged via the migration (categories_schema_version = 'v2').
-- Used ONLY for initializing new profile databases (not for the default profile).
CREATE TABLE IF NOT EXISTS import_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -39,6 +42,7 @@ CREATE TABLE IF NOT EXISTS categories (
is_active INTEGER NOT NULL DEFAULT 1,
is_inputable INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
i18n_key TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
@ -177,8 +181,477 @@ CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, mon
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
-- Default preferences
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
-- ============================================================================
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
-- ----------------------------------------------------------------------------
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
-- the historical v2 seed migration on this fresh DB before this script
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
-- ============================================================================
DELETE FROM keywords;
UPDATE transactions SET category_id = NULL;
DELETE FROM categories;
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
-- 1000 — Revenus
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
-- 1100 — Alimentation
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
-- 1200 — Logement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
-- 1300 — Ménage & ameublement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
-- 1400 — Vêtements & chaussures
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
-- 1500 — Transport
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
-- 1600 — Santé & soins personnels
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
-- 1700 — Loisirs, formation & lecture
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
-- 1800 — Boissons, tabac & cannabis
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
-- 1900 — Finances & obligations
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
-- 1950 — Transferts & placements
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
-- ============================================================================
-- Keywords — Canadian suppliers (150+ entries)
-- ============================================================================
-- Alimentation > Épicerie régulière (1111)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
-- Épicerie > Boucherie (1112)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
-- Épicerie > Boulangerie (1113)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
-- Épicerie > Dépanneur (1114)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
-- Restauration > Restaurant (1121)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
-- Restauration > Café (1122)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
-- Restauration > Fast food (1123)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
-- Restauration > Livraison (1124)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
-- Logement > Hypothèque (1212)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
-- Logement > Taxes municipales (1213)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
-- Électricité (1221)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
-- Gaz (1222)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
-- Internet (1231)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
-- Mobile (1232)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
-- Entretien maison (1241)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
-- Matériaux & outils (1243)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
-- Assurance habitation (1250)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
-- Meubles (1311)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
-- Électroménagers (1312)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
-- Décoration (1313)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
-- Vêtements (1410)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
-- Transport — Essence (1512)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
-- Permis / SAAQ (1514)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
-- Transport public — autobus/métro (1521)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
-- Train de banlieue (1522)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
-- Taxi / Uber (1523)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
-- Avion (1531)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
-- Hébergement (1533)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
-- Pharmacie (1611)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
-- Thérapies (1615)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
-- Cinéma & spectacles (1711)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
-- Jeux vidéo (1712)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
-- Streaming vidéo (1713)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
-- Streaming musique (1714)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
-- Jouets & hobbies (1715)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
-- Équipement sportif (1722)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
-- Parcs & activités (1723)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
-- Lecture (1741)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
-- Animaux — nourriture (1751)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
-- Alcool (1810)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
-- Cannabis (1820)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
-- Frais bancaires (1911)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
-- Impôts (1921, 1922)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
-- Dons de charité (1931)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
-- Cotisations professionnelles (1932)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
-- Cadeaux (1940)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
-- Divers (1946) — catch-all marketplace
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
-- Placements (1964)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
-- Paie (1011)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);

View file

@ -2,7 +2,8 @@ mod commands;
mod database;
use std::sync::Mutex;
use tauri::{Emitter, Listener};
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -81,9 +82,30 @@ pub fn run() {
UPDATE keywords SET category_id = 312 WHERE keyword = 'INS/ASS' AND category_id = 31;",
kind: MigrationKind::Up,
},
// Migration v8 — additive: tag existing profiles with the v2 categories
// taxonomy and add a nullable i18n_key column on categories so the v1
// IPC seed (applied only to brand-new profiles via consolidated_schema)
// can store translation keys. Existing v2 profiles are untouched: the
// column defaults to NULL (falling back to the category's `name`) and
// the preference is a no-op INSERT OR IGNORE so re-runs are safe.
Migration {
version: 8,
description: "add i18n_key to categories and categories_schema_version preference",
sql: "ALTER TABLE categories ADD COLUMN i18n_key TEXT;
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
kind: MigrationKind::Up,
},
];
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),
})
@ -95,15 +117,23 @@ pub fn run() {
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Listen for deep-link events (simpl-resultat://auth/callback?code=...)
// 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.listen("deep-link://new-url", move |event| {
let payload = event.payload();
// payload is a JSON-serialized array of URL strings
if let Ok(urls) = serde_json::from_str::<Vec<String>>(payload) {
for url in urls {
if let Some(code) = extract_auth_code(&url) {
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) => {
@ -114,7 +144,13 @@ pub fn run() {
}
}
});
}
} 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);
}
}
});
@ -161,6 +197,11 @@ pub fn run() {
commands::get_account_info,
commands::check_subscription_status,
commands::logout,
commands::get_token_store_mode,
commands::send_feedback,
commands::get_feedback_user_agent,
commands::ensure_backup_dir,
commands::get_file_size,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@ -169,6 +210,20 @@ pub fn run() {
/// 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;
@ -176,7 +231,7 @@ fn extract_auth_code(url: &str) -> Option<String> {
let query = url.split('?').nth(1)?;
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next()? == "code" {
if kv.next()? == key {
return kv.next().map(|v| {
urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string())
});

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat",
"version": "0.6.7",
"version": "0.8.3",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",
@ -18,12 +18,12 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
"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",

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

@ -129,7 +129,9 @@ function TreeRowContent({
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: node.color ?? "#9ca3af" }}
/>
<span className="flex-1 truncate">{node.name}</span>
<span className="flex-1 truncate">
{node.i18n_key ? t(node.i18n_key, { defaultValue: node.name }) : node.name}
</span>
<TypeBadge type={node.type} />
{node.keyword_count > 0 && (
<span className="text-[11px] text-[var(--muted-foreground)]">

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

@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
@ -25,6 +27,8 @@ function formatMonth(month: string): string {
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
export type CategoryOverTimeChartType = "bar" | "area";
interface CategoryOverTimeChartProps {
data: CategoryOverTimeData;
hiddenCategories: Set<string>;
@ -32,6 +36,13 @@ interface CategoryOverTimeChartProps {
onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void;
showAmounts?: boolean;
/**
* Visual rendering mode. `bar` (default) keeps the legacy stacked-bar
* composition. `area` stacks Recharts <Area> layers (stackId="1") for a
* smoother flow view. Both modes share the same palette and SVG grayscale
* patterns (existing signature visual).
*/
chartType?: CategoryOverTimeChartType;
}
export default function CategoryOverTimeChart({
@ -41,6 +52,7 @@ export default function CategoryOverTimeChart({
onShowAll,
onViewDetails,
showAmounts,
chartType = "bar",
}: CategoryOverTimeChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<string | null>(null);
@ -68,37 +80,16 @@ export default function CategoryOverTimeChart({
);
}
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
{Array.from(hiddenCategories).map((name) => (
<button
key={name}
onClick={() => onToggleHidden(name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
>
<Eye size={12} />
{name}
</button>
))}
<button
onClick={onShowAll}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("charts.showAll")}
</button>
</div>
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
// Shared chart configuration used by both Bar and Area variants.
const patternPrefix = "cat-time";
const patternDefs = (
<ChartPatternDefs
prefix="cat-time"
prefix={patternPrefix}
categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))}
/>
);
const commonAxes = (
<>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
@ -138,12 +129,64 @@ export default function CategoryOverTimeChart({
wrapperStyle={{ cursor: "pointer" }}
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
/>
</>
);
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
{Array.from(hiddenCategories).map((name) => (
<button
key={name}
onClick={() => onToggleHidden(name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
>
<Eye size={12} />
{name}
</button>
))}
<button
onClick={onShowAll}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("charts.showAll")}
</button>
</div>
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={400}>
{chartType === "area" ? (
<AreaChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
{patternDefs}
{commonAxes}
{categoryEntries.map((c) => (
<Area
key={c.name}
type="monotone"
dataKey={c.name}
stackId="1"
stroke={c.color}
fill={getPatternFill(patternPrefix, c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
style={{ transition: "fill-opacity 150ms", cursor: "context-menu" }}
/>
))}
</AreaChart>
) : (
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
{patternDefs}
{commonAxes}
{categoryEntries.map((c) => (
<Bar
key={c.name}
dataKey={c.name}
stackId="stack"
fill={getPatternFill("cat-time", c.index, c.color)}
fill={getPatternFill(patternPrefix, c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
@ -161,6 +204,7 @@ export default function CategoryOverTimeChart({
</Bar>
))}
</BarChart>
)}
</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,73 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types";
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<Category[]>([]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) =>
setCategories(
rows.map((r) => ({
id: r.id,
name: r.name,
parent_id: r.parent_id ?? undefined,
color: r.color ?? undefined,
icon: r.icon ?? undefined,
type: r.type,
is_active: r.is_active,
is_inputable: r.is_inputable,
sort_order: r.sort_order,
created_at: "",
})),
),
)
.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>
<CategoryCombobox
categories={categories}
value={categoryId}
onChange={onCategoryChange}
placeholder={t("reports.category.searchPlaceholder")}
ariaLabel={t("reports.category.selectCategory")}
/>
</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,253 @@
import { useTranslation } from "react-i18next";
import type { CategoryDelta } from "../../shared/types";
export interface ComparePeriodTableProps {
rows: CategoryDelta[];
/** Label for the "previous" monthly column (e.g. "March 2026" or "2025"). */
previousLabel: string;
/** Label for the "current" monthly column (e.g. "April 2026" or "2026"). */
currentLabel: string;
/** Optional label for the previous cumulative window (YTD). Falls back to previousLabel. */
cumulativePreviousLabel?: string;
/** Optional label for the current cumulative window (YTD). Falls back to currentLabel. */
cumulativeCurrentLabel?: 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);
}
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 variationColor(value: number): string {
// Compare report deals with expenses (abs values): spending more is negative
// for the user, spending less is positive. Mirror that colour convention
// consistently so the eye parses the delta sign at a glance.
if (value > 0) return "var(--negative, #ef4444)";
if (value < 0) return "var(--positive, #10b981)";
return "";
}
export default function ComparePeriodTable({
rows,
previousLabel,
currentLabel,
cumulativePreviousLabel,
cumulativeCurrentLabel,
}: ComparePeriodTableProps) {
const { t, i18n } = useTranslation();
const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Totals across all rows (there is no parent/child structure in compare mode).
const totals = rows.reduce(
(acc, r) => ({
monthCurrent: acc.monthCurrent + r.currentAmount,
monthPrevious: acc.monthPrevious + r.previousAmount,
monthDelta: acc.monthDelta + r.deltaAbs,
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
}),
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
);
const totalMonthPct =
totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null;
const totalYtdPct =
totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<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)]">
<th
rowSpan={2}
className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]"
>
{t("reports.highlights.category")}
</th>
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.monthly")}
</th>
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.ytd")}
</th>
</tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthCurrLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdCurrLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td
colSpan={9}
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)]/50 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
{/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, i18n.language)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, i18n.language)}
</td>
</tr>
))}
{/* Grand totals row */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.totalRow")}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.monthCurrent, i18n.language)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatSignedCurrency(totals.monthDelta, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatPct(totalMonthPct, i18n.language)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, i18n.language)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatSignedCurrency(totals.ytdDelta, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatPct(totalYtdPct, 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,55 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readTrendsChartType, TRENDS_CHART_TYPE_STORAGE_KEY } from "./TrendsChartTypeToggle";
describe("readTrendsChartType", () => {
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 'bar' fallback when key is missing", () => {
expect(readTrendsChartType()).toBe("bar");
});
it("returns 'bar' when stored value is 'bar'", () => {
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "bar");
expect(readTrendsChartType()).toBe("bar");
});
it("migrates legacy 'line' stored value to 'bar'", () => {
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "line");
expect(readTrendsChartType()).toBe("bar");
});
it("returns 'area' when stored value is 'area'", () => {
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "area");
expect(readTrendsChartType()).toBe("area");
});
it("ignores invalid stored values and returns fallback", () => {
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "bogus");
expect(readTrendsChartType(TRENDS_CHART_TYPE_STORAGE_KEY, "area")).toBe("area");
});
it("respects custom fallback when provided", () => {
expect(readTrendsChartType(TRENDS_CHART_TYPE_STORAGE_KEY, "area")).toBe("area");
});
it("uses the expected storage key", () => {
expect(TRENDS_CHART_TYPE_STORAGE_KEY).toBe("reports-trends-category-charttype");
});
});

View file

@ -0,0 +1,62 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3 as BarIcon, AreaChart as AreaIcon } from "lucide-react";
import type { CategoryOverTimeChartType } from "./CategoryOverTimeChart";
export const TRENDS_CHART_TYPE_STORAGE_KEY = "reports-trends-category-charttype";
export interface TrendsChartTypeToggleProps {
value: CategoryOverTimeChartType;
onChange: (value: CategoryOverTimeChartType) => void;
/** localStorage key used to persist the preference. */
storageKey?: string;
}
export function readTrendsChartType(
storageKey: string = TRENDS_CHART_TYPE_STORAGE_KEY,
fallback: CategoryOverTimeChartType = "bar",
): CategoryOverTimeChartType {
if (typeof localStorage === "undefined") return fallback;
const saved = localStorage.getItem(storageKey);
// Back-compat: "line" was the historical key for the bar chart.
if (saved === "line") return "bar";
return saved === "bar" || saved === "area" ? saved : fallback;
}
export default function TrendsChartTypeToggle({
value,
onChange,
storageKey = TRENDS_CHART_TYPE_STORAGE_KEY,
}: TrendsChartTypeToggleProps) {
const { t } = useTranslation();
useEffect(() => {
if (storageKey) localStorage.setItem(storageKey, value);
}, [value, storageKey]);
const options: { type: CategoryOverTimeChartType; icon: React.ReactNode; label: string }[] = [
{ type: "bar", icon: <BarIcon size={14} />, label: t("reports.trends.chartBar") },
{ type: "area", icon: <AreaIcon size={14} />, label: t("reports.trends.chartArea") },
];
return (
<div className="inline-flex gap-1" role="group" aria-label={t("reports.trends.chartTypeAria")}>
{options.map(({ type, icon, label }) => (
<button
key={type}
type="button"
onClick={() => onChange(type)}
aria-pressed={value === type}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === type
? "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,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,60 @@
import { useTranslation } from "react-i18next";
import { Calendar as MonthIcon, CalendarRange as YtdIcon } from "lucide-react";
import type { CartesKpiPeriodMode } from "../../../shared/types";
export interface CartesPeriodModeToggleProps {
value: CartesKpiPeriodMode;
onChange: (value: CartesKpiPeriodMode) => void;
}
/**
* Segmented toggle that flips the four Cartes KPI cards between the reference
* month (current default) and a Year-to-Date view. The 13-month sparkline and
* the Seasonality / Top Movers / Budget Adherence widgets are unaffected they
* always remain monthly by design. Persistence is owned by the parent hook
* (`useCartes`); this component is a controlled input only.
*/
export default function CartesPeriodModeToggle({
value,
onChange,
}: CartesPeriodModeToggleProps) {
const { t } = useTranslation();
const options: { type: CartesKpiPeriodMode; icon: React.ReactNode; label: string }[] = [
{
type: "month",
icon: <MonthIcon size={14} />,
label: t("reports.cartes.periodMode.month"),
},
{
type: "ytd",
icon: <YtdIcon size={14} />,
label: t("reports.cartes.periodMode.ytd"),
},
];
return (
<div
className="inline-flex gap-1"
role="group"
aria-label={t("reports.cartes.periodMode.aria")}
>
{options.map(({ type, icon, label }) => (
<button
key={type}
type="button"
onClick={() => onChange(type)}
aria-pressed={value === type}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === type
? "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,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,154 @@
import { useTranslation } from "react-i18next";
import { HelpCircle } from "lucide-react";
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;
/** Optional help text shown on hover of a (?) icon next to the title. */
tooltip?: 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);
}
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,
tooltip,
}: 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)] flex items-center gap-1">
<span>{title}</span>
{tooltip && (
<span
title={tooltip}
aria-label={tooltip}
className="inline-flex items-center text-[var(--muted-foreground)] cursor-help"
>
<HelpCircle size={12} aria-hidden="true" />
</span>
)}
</div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{kpi.current === null ? "—" : 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,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,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,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Category } from "../../shared/types";
interface CategoryComboboxProps {
@ -7,6 +8,7 @@ interface CategoryComboboxProps {
onChange: (id: number | null) => void;
placeholder?: string;
compact?: boolean;
ariaLabel?: string;
/** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */
extraOptions?: Array<{ value: string; label: string }>;
/** Called when an extra option is selected */
@ -15,38 +17,75 @@ interface CategoryComboboxProps {
activeExtra?: string | null;
}
// Strip accents + lowercase for accent-insensitive matching
function normalize(s: string): string {
return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
// Compute depth of each category based on parent_id chain
function computeDepths(categories: Category[]): Map<number, number> {
const byId = new Map<number, Category>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
function depthOf(id: number, seen: Set<number>): number {
if (depths.has(id)) return depths.get(id)!;
if (seen.has(id)) return 0;
seen.add(id);
const cat = byId.get(id);
if (!cat || cat.parent_id == null) {
depths.set(id, 0);
return 0;
}
const d = depthOf(cat.parent_id, seen) + 1;
depths.set(id, d);
return d;
}
for (const c of categories) depthOf(c.id, new Set());
return depths;
}
export default function CategoryCombobox({
categories,
value,
onChange,
placeholder = "",
compact = false,
ariaLabel,
extraOptions,
onExtraSelect,
activeExtra,
}: CategoryComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const listboxId = `${baseId}-listbox`;
const optionId = (i: number) => `${baseId}-option-${i}`;
const depths = useMemo(() => computeDepths(categories), [categories]);
// Resolve the display name for a category: seed rows carry an i18n_key that
// we translate with name as defaultValue; user-created rows just use name.
const displayName = useCallback(
(c: Category) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name),
[t]
);
// Build display label
const selectedCategory = categories.find((c) => c.id === value);
const displayLabel =
activeExtra != null
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
: selectedCategory?.name ?? "";
: selectedCategory
? displayName(selectedCategory)
: "";
// Strip accents + lowercase for accent-insensitive matching
const normalize = (s: string) =>
s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
// Filter categories
const normalizedQuery = normalize(query);
const filtered = query
? categories.filter((c) => normalize(c.name).includes(normalizedQuery))
? categories.filter((c) => normalize(displayName(c)).includes(normalizedQuery))
: categories;
const filteredExtras = extraOptions
@ -57,7 +96,6 @@ export default function CategoryCombobox({
const totalItems = filteredExtras.length + filtered.length;
// Scroll highlighted item into view
useEffect(() => {
if (open && listRef.current) {
const el = listRef.current.children[highlightIndex] as HTMLElement | undefined;
@ -65,7 +103,6 @@ export default function CategoryCombobox({
}
}, [highlightIndex, open]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
@ -128,11 +165,19 @@ export default function CategoryCombobox({
const py = compact ? "py-1" : "py-2";
const px = compact ? "px-2" : "px-3";
const activeId = open && totalItems > 0 ? optionId(highlightIndex) : undefined;
return (
<div ref={containerRef} className="relative">
<input
ref={inputRef}
type="text"
role="combobox"
aria-label={ariaLabel}
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeId}
value={open ? query : displayLabel}
placeholder={placeholder || displayLabel}
onChange={(e) => {
@ -151,11 +196,16 @@ export default function CategoryCombobox({
{open && totalItems > 0 && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
>
{filteredExtras.map((opt, i) => (
<li
key={`extra-${opt.value}`}
id={optionId(i)}
role="option"
aria-selected={i === highlightIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectItem(i)}
onMouseEnter={() => setHighlightIndex(i)}
@ -170,9 +220,14 @@ export default function CategoryCombobox({
))}
{filtered.map((cat, i) => {
const idx = filteredExtras.length + i;
const depth = depths.get(cat.id) ?? 0;
const indent = depth > 0 ? " ".repeat(depth) : "";
return (
<li
key={cat.id}
id={optionId(idx)}
role="option"
aria-selected={idx === highlightIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectItem(idx)}
onMouseEnter={() => setHighlightIndex(idx)}
@ -181,8 +236,10 @@ export default function CategoryCombobox({
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
style={depth > 0 ? { paddingLeft: `calc(${compact ? "0.5rem" : "0.75rem"} + ${depth * 1}rem)` } : undefined}
>
{cat.name}
<span className="whitespace-pre">{indent}</span>
{displayName(cat)}
</li>
);
})}

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

@ -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) =>

View file

@ -0,0 +1,498 @@
{
"version": "v1",
"description": "IPC Statistique Canada-aligned category taxonomy, 3 levels, Canada/Québec. Derived from src-tauri/src/database/consolidated_schema.sql — keep the two in sync when rolling a new seed version.",
"roots": [
{
"id": 1000,
"name": "Revenus",
"i18n_key": "categoriesSeed.revenus.root",
"type": "income",
"color": "#16a34a",
"sort_order": 1,
"children": [
{
"id": 1010,
"name": "Emploi",
"i18n_key": "categoriesSeed.revenus.emploi.root",
"type": "income",
"color": "#22c55e",
"sort_order": 1,
"children": [
{ "id": 1011, "name": "Paie régulière", "i18n_key": "categoriesSeed.revenus.emploi.paie", "type": "income", "color": "#22c55e", "sort_order": 1, "children": [] },
{ "id": 1012, "name": "Primes & bonus", "i18n_key": "categoriesSeed.revenus.emploi.primes", "type": "income", "color": "#4ade80", "sort_order": 2, "children": [] },
{ "id": 1013, "name": "Travail autonome", "i18n_key": "categoriesSeed.revenus.emploi.autonome", "type": "income", "color": "#86efac", "sort_order": 3, "children": [] }
]
},
{
"id": 1020,
"name": "Gouvernemental",
"i18n_key": "categoriesSeed.revenus.gouvernemental.root",
"type": "income",
"color": "#16a34a",
"sort_order": 2,
"children": [
{ "id": 1021, "name": "Remboursement impôt", "i18n_key": "categoriesSeed.revenus.gouvernemental.remboursementImpot", "type": "income", "color": "#16a34a", "sort_order": 1, "children": [] },
{ "id": 1022, "name": "Allocations familiales", "i18n_key": "categoriesSeed.revenus.gouvernemental.allocations", "type": "income", "color": "#15803d", "sort_order": 2, "children": [] },
{ "id": 1023, "name": "Crédits TPS/TVQ", "i18n_key": "categoriesSeed.revenus.gouvernemental.credits", "type": "income", "color": "#166534", "sort_order": 3, "children": [] },
{ "id": 1024, "name": "Assurance-emploi / RQAP", "i18n_key": "categoriesSeed.revenus.gouvernemental.assuranceEmploi", "type": "income", "color": "#14532d", "sort_order": 4, "children": [] }
]
},
{
"id": 1030,
"name": "Revenus de placement",
"i18n_key": "categoriesSeed.revenus.placement.root",
"type": "income",
"color": "#10b981",
"sort_order": 3,
"children": [
{ "id": 1031, "name": "Intérêts & dividendes", "i18n_key": "categoriesSeed.revenus.placement.interets", "type": "income", "color": "#10b981", "sort_order": 1, "children": [] },
{ "id": 1032, "name": "Gains en capital", "i18n_key": "categoriesSeed.revenus.placement.capital", "type": "income", "color": "#059669", "sort_order": 2, "children": [] },
{ "id": 1033, "name": "Revenus locatifs", "i18n_key": "categoriesSeed.revenus.placement.locatifs", "type": "income", "color": "#047857", "sort_order": 3, "children": [] }
]
},
{ "id": 1090, "name": "Autres revenus", "i18n_key": "categoriesSeed.revenus.autres", "type": "income", "color": "#84cc16", "sort_order": 9, "children": [] }
]
},
{
"id": 1100,
"name": "Alimentation",
"i18n_key": "categoriesSeed.alimentation.root",
"type": "expense",
"color": "#ea580c",
"sort_order": 2,
"children": [
{
"id": 1110,
"name": "Épicerie & marché",
"i18n_key": "categoriesSeed.alimentation.epicerie.root",
"type": "expense",
"color": "#ea580c",
"sort_order": 1,
"children": [
{ "id": 1111, "name": "Épicerie régulière", "i18n_key": "categoriesSeed.alimentation.epicerie.reguliere", "type": "expense", "color": "#ea580c", "sort_order": 1, "children": [] },
{ "id": 1112, "name": "Boucherie & poissonnerie", "i18n_key": "categoriesSeed.alimentation.epicerie.boucherie", "type": "expense", "color": "#c2410c", "sort_order": 2, "children": [] },
{ "id": 1113, "name": "Boulangerie & pâtisserie", "i18n_key": "categoriesSeed.alimentation.epicerie.boulangerie", "type": "expense", "color": "#9a3412", "sort_order": 3, "children": [] },
{ "id": 1114, "name": "Dépanneur", "i18n_key": "categoriesSeed.alimentation.epicerie.depanneur", "type": "expense", "color": "#7c2d12", "sort_order": 4, "children": [] },
{ "id": 1115, "name": "Marché & produits spécialisés", "i18n_key": "categoriesSeed.alimentation.epicerie.marche", "type": "expense", "color": "#fb923c", "sort_order": 5, "children": [] }
]
},
{
"id": 1120,
"name": "Restauration",
"i18n_key": "categoriesSeed.alimentation.restauration.root",
"type": "expense",
"color": "#f97316",
"sort_order": 2,
"children": [
{ "id": 1121, "name": "Restaurant", "i18n_key": "categoriesSeed.alimentation.restauration.restaurant", "type": "expense", "color": "#f97316", "sort_order": 1, "children": [] },
{ "id": 1122, "name": "Café & boulangerie rapide", "i18n_key": "categoriesSeed.alimentation.restauration.cafe", "type": "expense", "color": "#fb923c", "sort_order": 2, "children": [] },
{ "id": 1123, "name": "Restauration rapide", "i18n_key": "categoriesSeed.alimentation.restauration.fastfood", "type": "expense", "color": "#fdba74", "sort_order": 3, "children": [] },
{ "id": 1124, "name": "Livraison à domicile", "i18n_key": "categoriesSeed.alimentation.restauration.livraison", "type": "expense", "color": "#fed7aa", "sort_order": 4, "children": [] },
{ "id": 1125, "name": "Cantine & cafétéria", "i18n_key": "categoriesSeed.alimentation.restauration.cantine", "type": "expense", "color": "#ffedd5", "sort_order": 5, "children": [] }
]
}
]
},
{
"id": 1200,
"name": "Logement",
"i18n_key": "categoriesSeed.logement.root",
"type": "expense",
"color": "#dc2626",
"sort_order": 3,
"children": [
{
"id": 1210,
"name": "Habitation principale",
"i18n_key": "categoriesSeed.logement.habitation.root",
"type": "expense",
"color": "#dc2626",
"sort_order": 1,
"children": [
{ "id": 1211, "name": "Loyer", "i18n_key": "categoriesSeed.logement.habitation.loyer", "type": "expense", "color": "#dc2626", "sort_order": 1, "children": [] },
{ "id": 1212, "name": "Hypothèque", "i18n_key": "categoriesSeed.logement.habitation.hypotheque", "type": "expense", "color": "#b91c1c", "sort_order": 2, "children": [] },
{ "id": 1213, "name": "Taxes municipales & scolaires", "i18n_key": "categoriesSeed.logement.habitation.taxes", "type": "expense", "color": "#991b1b", "sort_order": 3, "children": [] },
{ "id": 1214, "name": "Charges de copropriété", "i18n_key": "categoriesSeed.logement.habitation.copropriete", "type": "expense", "color": "#7f1d1d", "sort_order": 4, "children": [] }
]
},
{
"id": 1220,
"name": "Services publics",
"i18n_key": "categoriesSeed.logement.services.root",
"type": "expense",
"color": "#ef4444",
"sort_order": 2,
"children": [
{ "id": 1221, "name": "Électricité", "i18n_key": "categoriesSeed.logement.services.electricite", "type": "expense", "color": "#ef4444", "sort_order": 1, "children": [] },
{ "id": 1222, "name": "Gaz naturel", "i18n_key": "categoriesSeed.logement.services.gaz", "type": "expense", "color": "#f87171", "sort_order": 2, "children": [] },
{ "id": 1223, "name": "Chauffage (mazout, propane)", "i18n_key": "categoriesSeed.logement.services.chauffage", "type": "expense", "color": "#fca5a5", "sort_order": 3, "children": [] },
{ "id": 1224, "name": "Eau & égouts", "i18n_key": "categoriesSeed.logement.services.eau", "type": "expense", "color": "#fecaca", "sort_order": 4, "children": [] }
]
},
{
"id": 1230,
"name": "Communications",
"i18n_key": "categoriesSeed.logement.communications.root",
"type": "expense",
"color": "#6366f1",
"sort_order": 3,
"children": [
{ "id": 1231, "name": "Internet résidentiel", "i18n_key": "categoriesSeed.logement.communications.internet", "type": "expense", "color": "#6366f1", "sort_order": 1, "children": [] },
{ "id": 1232, "name": "Téléphonie mobile", "i18n_key": "categoriesSeed.logement.communications.mobile", "type": "expense", "color": "#818cf8", "sort_order": 2, "children": [] },
{ "id": 1233, "name": "Téléphonie résidentielle", "i18n_key": "categoriesSeed.logement.communications.residentielle", "type": "expense", "color": "#a5b4fc", "sort_order": 3, "children": [] },
{ "id": 1234, "name": "Câblodistribution & streaming TV", "i18n_key": "categoriesSeed.logement.communications.streamingTv", "type": "expense", "color": "#c7d2fe", "sort_order": 4, "children": [] }
]
},
{
"id": 1240,
"name": "Entretien & réparations",
"i18n_key": "categoriesSeed.logement.entretien.root",
"type": "expense",
"color": "#e11d48",
"sort_order": 4,
"children": [
{ "id": 1241, "name": "Entretien général", "i18n_key": "categoriesSeed.logement.entretien.general", "type": "expense", "color": "#e11d48", "sort_order": 1, "children": [] },
{ "id": 1242, "name": "Rénovations", "i18n_key": "categoriesSeed.logement.entretien.renovations", "type": "expense", "color": "#be123c", "sort_order": 2, "children": [] },
{ "id": 1243, "name": "Matériaux & outils", "i18n_key": "categoriesSeed.logement.entretien.materiaux", "type": "expense", "color": "#9f1239", "sort_order": 3, "children": [] },
{ "id": 1244, "name": "Aménagement paysager", "i18n_key": "categoriesSeed.logement.entretien.paysager", "type": "expense", "color": "#881337", "sort_order": 4, "children": [] }
]
},
{ "id": 1250, "name": "Assurance habitation", "i18n_key": "categoriesSeed.logement.assurance", "type": "expense", "color": "#14b8a6", "sort_order": 5, "children": [] }
]
},
{
"id": 1300,
"name": "Ménage & ameublement",
"i18n_key": "categoriesSeed.menage.root",
"type": "expense",
"color": "#ca8a04",
"sort_order": 4,
"children": [
{
"id": 1310,
"name": "Ameublement",
"i18n_key": "categoriesSeed.menage.ameublement.root",
"type": "expense",
"color": "#ca8a04",
"sort_order": 1,
"children": [
{ "id": 1311, "name": "Meubles", "i18n_key": "categoriesSeed.menage.ameublement.meubles", "type": "expense", "color": "#ca8a04", "sort_order": 1, "children": [] },
{ "id": 1312, "name": "Électroménagers", "i18n_key": "categoriesSeed.menage.ameublement.electromenagers", "type": "expense", "color": "#a16207", "sort_order": 2, "children": [] },
{ "id": 1313, "name": "Décoration", "i18n_key": "categoriesSeed.menage.ameublement.decoration", "type": "expense", "color": "#854d0e", "sort_order": 3, "children": [] }
]
},
{
"id": 1320,
"name": "Fournitures ménagères",
"i18n_key": "categoriesSeed.menage.fournitures.root",
"type": "expense",
"color": "#eab308",
"sort_order": 2,
"children": [
{ "id": 1321, "name": "Produits d'entretien", "i18n_key": "categoriesSeed.menage.fournitures.entretien", "type": "expense", "color": "#eab308", "sort_order": 1, "children": [] },
{ "id": 1322, "name": "Literie & linge de maison", "i18n_key": "categoriesSeed.menage.fournitures.literie", "type": "expense", "color": "#facc15", "sort_order": 2, "children": [] },
{ "id": 1323, "name": "Vaisselle & ustensiles", "i18n_key": "categoriesSeed.menage.fournitures.vaisselle", "type": "expense", "color": "#fde047", "sort_order": 3, "children": [] }
]
},
{
"id": 1330,
"name": "Services domestiques",
"i18n_key": "categoriesSeed.menage.services.root",
"type": "expense",
"color": "#fbbf24",
"sort_order": 3,
"children": [
{ "id": 1331, "name": "Ménage & nettoyage", "i18n_key": "categoriesSeed.menage.services.nettoyage", "type": "expense", "color": "#fbbf24", "sort_order": 1, "children": [] },
{ "id": 1332, "name": "Buanderie & pressing", "i18n_key": "categoriesSeed.menage.services.buanderie", "type": "expense", "color": "#fcd34d", "sort_order": 2, "children": [] }
]
}
]
},
{
"id": 1400,
"name": "Vêtements & chaussures",
"i18n_key": "categoriesSeed.vetements.root",
"type": "expense",
"color": "#d946ef",
"sort_order": 5,
"children": [
{ "id": 1410, "name": "Vêtements adultes", "i18n_key": "categoriesSeed.vetements.adultes", "type": "expense", "color": "#d946ef", "sort_order": 1, "children": [] },
{ "id": 1420, "name": "Vêtements enfants", "i18n_key": "categoriesSeed.vetements.enfants", "type": "expense", "color": "#c026d3", "sort_order": 2, "children": [] },
{ "id": 1430, "name": "Chaussures", "i18n_key": "categoriesSeed.vetements.chaussures", "type": "expense", "color": "#a21caf", "sort_order": 3, "children": [] },
{ "id": 1440, "name": "Accessoires & bijoux", "i18n_key": "categoriesSeed.vetements.accessoires", "type": "expense", "color": "#86198f", "sort_order": 4, "children": [] }
]
},
{
"id": 1500,
"name": "Transport",
"i18n_key": "categoriesSeed.transport.root",
"type": "expense",
"color": "#2563eb",
"sort_order": 6,
"children": [
{
"id": 1510,
"name": "Véhicule personnel",
"i18n_key": "categoriesSeed.transport.vehicule.root",
"type": "expense",
"color": "#2563eb",
"sort_order": 1,
"children": [
{ "id": 1511, "name": "Achat / location véhicule", "i18n_key": "categoriesSeed.transport.vehicule.achat", "type": "expense", "color": "#2563eb", "sort_order": 1, "children": [] },
{ "id": 1512, "name": "Essence", "i18n_key": "categoriesSeed.transport.vehicule.essence", "type": "expense", "color": "#1d4ed8", "sort_order": 2, "children": [] },
{ "id": 1513, "name": "Entretien & réparations auto", "i18n_key": "categoriesSeed.transport.vehicule.entretien", "type": "expense", "color": "#1e40af", "sort_order": 3, "children": [] },
{ "id": 1514, "name": "Immatriculation & permis", "i18n_key": "categoriesSeed.transport.vehicule.immatriculation", "type": "expense", "color": "#1e3a8a", "sort_order": 4, "children": [] },
{ "id": 1515, "name": "Stationnement & péages", "i18n_key": "categoriesSeed.transport.vehicule.stationnement", "type": "expense", "color": "#3b82f6", "sort_order": 5, "children": [] },
{ "id": 1516, "name": "Assurance auto", "i18n_key": "categoriesSeed.transport.vehicule.assurance", "type": "expense", "color": "#60a5fa", "sort_order": 6, "children": [] }
]
},
{
"id": 1520,
"name": "Transport public",
"i18n_key": "categoriesSeed.transport.public.root",
"type": "expense",
"color": "#0ea5e9",
"sort_order": 2,
"children": [
{ "id": 1521, "name": "Autobus & métro", "i18n_key": "categoriesSeed.transport.public.autobus", "type": "expense", "color": "#0ea5e9", "sort_order": 1, "children": [] },
{ "id": 1522, "name": "Train de banlieue", "i18n_key": "categoriesSeed.transport.public.train", "type": "expense", "color": "#0284c7", "sort_order": 2, "children": [] },
{ "id": 1523, "name": "Taxi & covoiturage", "i18n_key": "categoriesSeed.transport.public.taxi", "type": "expense", "color": "#0369a1", "sort_order": 3, "children": [] }
]
},
{
"id": 1530,
"name": "Voyages longue distance",
"i18n_key": "categoriesSeed.transport.voyages.root",
"type": "expense",
"color": "#38bdf8",
"sort_order": 3,
"children": [
{ "id": 1531, "name": "Avion", "i18n_key": "categoriesSeed.transport.voyages.avion", "type": "expense", "color": "#38bdf8", "sort_order": 1, "children": [] },
{ "id": 1532, "name": "Train & autocar", "i18n_key": "categoriesSeed.transport.voyages.trainAutocar", "type": "expense", "color": "#7dd3fc", "sort_order": 2, "children": [] },
{ "id": 1533, "name": "Hébergement", "i18n_key": "categoriesSeed.transport.voyages.hebergement", "type": "expense", "color": "#bae6fd", "sort_order": 3, "children": [] },
{ "id": 1534, "name": "Location véhicule voyage", "i18n_key": "categoriesSeed.transport.voyages.location", "type": "expense", "color": "#e0f2fe", "sort_order": 4, "children": [] }
]
}
]
},
{
"id": 1600,
"name": "Santé & soins personnels",
"i18n_key": "categoriesSeed.sante.root",
"type": "expense",
"color": "#f43f5e",
"sort_order": 7,
"children": [
{
"id": 1610,
"name": "Soins médicaux",
"i18n_key": "categoriesSeed.sante.medicaux.root",
"type": "expense",
"color": "#f43f5e",
"sort_order": 1,
"children": [
{ "id": 1611, "name": "Pharmacie", "i18n_key": "categoriesSeed.sante.medicaux.pharmacie", "type": "expense", "color": "#f43f5e", "sort_order": 1, "children": [] },
{ "id": 1612, "name": "Consultations médicales", "i18n_key": "categoriesSeed.sante.medicaux.consultations", "type": "expense", "color": "#e11d48", "sort_order": 2, "children": [] },
{ "id": 1613, "name": "Dentiste & orthodontiste", "i18n_key": "categoriesSeed.sante.medicaux.dentiste", "type": "expense", "color": "#be123c", "sort_order": 3, "children": [] },
{ "id": 1614, "name": "Optométrie & lunettes", "i18n_key": "categoriesSeed.sante.medicaux.optometrie", "type": "expense", "color": "#9f1239", "sort_order": 4, "children": [] },
{ "id": 1615, "name": "Thérapies (physio, psycho, etc.)", "i18n_key": "categoriesSeed.sante.medicaux.therapies", "type": "expense", "color": "#881337", "sort_order": 5, "children": [] },
{ "id": 1616, "name": "Assurance santé complémentaire", "i18n_key": "categoriesSeed.sante.medicaux.assurance", "type": "expense", "color": "#fb7185", "sort_order": 6, "children": [] }
]
},
{
"id": 1620,
"name": "Soins personnels",
"i18n_key": "categoriesSeed.sante.personnels.root",
"type": "expense",
"color": "#fb7185",
"sort_order": 2,
"children": [
{ "id": 1621, "name": "Coiffure & esthétique", "i18n_key": "categoriesSeed.sante.personnels.coiffure", "type": "expense", "color": "#fb7185", "sort_order": 1, "children": [] },
{ "id": 1622, "name": "Produits de soins corporels", "i18n_key": "categoriesSeed.sante.personnels.soins", "type": "expense", "color": "#fda4af", "sort_order": 2, "children": [] }
]
},
{ "id": 1630, "name": "Assurance vie & invalidité", "i18n_key": "categoriesSeed.sante.assuranceVie", "type": "expense", "color": "#14b8a6", "sort_order": 3, "children": [] }
]
},
{
"id": 1700,
"name": "Loisirs, formation & lecture",
"i18n_key": "categoriesSeed.loisirs.root",
"type": "expense",
"color": "#8b5cf6",
"sort_order": 8,
"children": [
{
"id": 1710,
"name": "Divertissement",
"i18n_key": "categoriesSeed.loisirs.divertissement.root",
"type": "expense",
"color": "#8b5cf6",
"sort_order": 1,
"children": [
{ "id": 1711, "name": "Cinéma & spectacles", "i18n_key": "categoriesSeed.loisirs.divertissement.cinema", "type": "expense", "color": "#8b5cf6", "sort_order": 1, "children": [] },
{ "id": 1712, "name": "Jeux vidéo & consoles", "i18n_key": "categoriesSeed.loisirs.divertissement.jeux", "type": "expense", "color": "#a78bfa", "sort_order": 2, "children": [] },
{ "id": 1713, "name": "Streaming vidéo", "i18n_key": "categoriesSeed.loisirs.divertissement.streamingVideo", "type": "expense", "color": "#c4b5fd", "sort_order": 3, "children": [] },
{ "id": 1714, "name": "Streaming musique & audio", "i18n_key": "categoriesSeed.loisirs.divertissement.streamingMusique", "type": "expense", "color": "#ddd6fe", "sort_order": 4, "children": [] },
{ "id": 1715, "name": "Jouets & passe-temps", "i18n_key": "categoriesSeed.loisirs.divertissement.jouets", "type": "expense", "color": "#ede9fe", "sort_order": 5, "children": [] }
]
},
{
"id": 1720,
"name": "Sports & plein air",
"i18n_key": "categoriesSeed.loisirs.sports.root",
"type": "expense",
"color": "#22c55e",
"sort_order": 2,
"children": [
{ "id": 1721, "name": "Abonnements sportifs", "i18n_key": "categoriesSeed.loisirs.sports.abonnements", "type": "expense", "color": "#22c55e", "sort_order": 1, "children": [] },
{ "id": 1722, "name": "Équipement sportif", "i18n_key": "categoriesSeed.loisirs.sports.equipement", "type": "expense", "color": "#4ade80", "sort_order": 2, "children": [] },
{ "id": 1723, "name": "Parcs & activités plein air", "i18n_key": "categoriesSeed.loisirs.sports.parcs", "type": "expense", "color": "#86efac", "sort_order": 3, "children": [] }
]
},
{
"id": 1730,
"name": "Formation & éducation",
"i18n_key": "categoriesSeed.loisirs.formation.root",
"type": "expense",
"color": "#6366f1",
"sort_order": 3,
"children": [
{ "id": 1731, "name": "Scolarité (frais)", "i18n_key": "categoriesSeed.loisirs.formation.scolarite", "type": "expense", "color": "#6366f1", "sort_order": 1, "children": [] },
{ "id": 1732, "name": "Matériel scolaire", "i18n_key": "categoriesSeed.loisirs.formation.materiel", "type": "expense", "color": "#818cf8", "sort_order": 2, "children": [] },
{ "id": 1733, "name": "Cours & certifications", "i18n_key": "categoriesSeed.loisirs.formation.cours", "type": "expense", "color": "#a5b4fc", "sort_order": 3, "children": [] },
{ "id": 1734, "name": "Abonnements professionnels", "i18n_key": "categoriesSeed.loisirs.formation.abonnements", "type": "expense", "color": "#c7d2fe", "sort_order": 4, "children": [] }
]
},
{
"id": 1740,
"name": "Lecture & médias",
"i18n_key": "categoriesSeed.loisirs.lecture.root",
"type": "expense",
"color": "#ec4899",
"sort_order": 4,
"children": [
{ "id": 1741, "name": "Livres", "i18n_key": "categoriesSeed.loisirs.lecture.livres", "type": "expense", "color": "#ec4899", "sort_order": 1, "children": [] },
{ "id": 1742, "name": "Journaux & magazines", "i18n_key": "categoriesSeed.loisirs.lecture.journaux", "type": "expense", "color": "#f472b6", "sort_order": 2, "children": [] }
]
},
{
"id": 1750,
"name": "Animaux de compagnie",
"i18n_key": "categoriesSeed.loisirs.animaux.root",
"type": "expense",
"color": "#a855f7",
"sort_order": 5,
"children": [
{ "id": 1751, "name": "Nourriture & accessoires animaux", "i18n_key": "categoriesSeed.loisirs.animaux.nourriture", "type": "expense", "color": "#a855f7", "sort_order": 1, "children": [] },
{ "id": 1752, "name": "Vétérinaire", "i18n_key": "categoriesSeed.loisirs.animaux.veterinaire", "type": "expense", "color": "#c084fc", "sort_order": 2, "children": [] }
]
}
]
},
{
"id": 1800,
"name": "Boissons, tabac & cannabis",
"i18n_key": "categoriesSeed.consommation.root",
"type": "expense",
"color": "#7c3aed",
"sort_order": 9,
"children": [
{ "id": 1810, "name": "Alcool (SAQ, microbrasseries)", "i18n_key": "categoriesSeed.consommation.alcool", "type": "expense", "color": "#7c3aed", "sort_order": 1, "children": [] },
{ "id": 1820, "name": "Cannabis (SQDC)", "i18n_key": "categoriesSeed.consommation.cannabis", "type": "expense", "color": "#6d28d9", "sort_order": 2, "children": [] },
{ "id": 1830, "name": "Tabac", "i18n_key": "categoriesSeed.consommation.tabac", "type": "expense", "color": "#5b21b6", "sort_order": 3, "children": [] }
]
},
{
"id": 1900,
"name": "Finances & obligations",
"i18n_key": "categoriesSeed.finances.root",
"type": "expense",
"color": "#6b7280",
"sort_order": 10,
"children": [
{
"id": 1910,
"name": "Frais bancaires",
"i18n_key": "categoriesSeed.finances.fraisBancaires.root",
"type": "expense",
"color": "#6b7280",
"sort_order": 1,
"children": [
{ "id": 1911, "name": "Frais de compte", "i18n_key": "categoriesSeed.finances.fraisBancaires.compte", "type": "expense", "color": "#6b7280", "sort_order": 1, "children": [] },
{ "id": 1912, "name": "Intérêts & frais de crédit", "i18n_key": "categoriesSeed.finances.fraisBancaires.interets", "type": "expense", "color": "#9ca3af", "sort_order": 2, "children": [] },
{ "id": 1913, "name": "Frais de change", "i18n_key": "categoriesSeed.finances.fraisBancaires.change", "type": "expense", "color": "#d1d5db", "sort_order": 3, "children": [] }
]
},
{
"id": 1920,
"name": "Impôts & taxes",
"i18n_key": "categoriesSeed.finances.impots.root",
"type": "expense",
"color": "#dc2626",
"sort_order": 2,
"children": [
{ "id": 1921, "name": "Impôt fédéral", "i18n_key": "categoriesSeed.finances.impots.federal", "type": "expense", "color": "#dc2626", "sort_order": 1, "children": [] },
{ "id": 1922, "name": "Impôt provincial", "i18n_key": "categoriesSeed.finances.impots.provincial", "type": "expense", "color": "#b91c1c", "sort_order": 2, "children": [] },
{ "id": 1923, "name": "Acomptes provisionnels", "i18n_key": "categoriesSeed.finances.impots.acomptes", "type": "expense", "color": "#991b1b", "sort_order": 3, "children": [] }
]
},
{
"id": 1930,
"name": "Dons & cotisations",
"i18n_key": "categoriesSeed.finances.dons.root",
"type": "expense",
"color": "#ec4899",
"sort_order": 3,
"children": [
{ "id": 1931, "name": "Dons de charité", "i18n_key": "categoriesSeed.finances.dons.charite", "type": "expense", "color": "#ec4899", "sort_order": 1, "children": [] },
{ "id": 1932, "name": "Cotisations professionnelles", "i18n_key": "categoriesSeed.finances.dons.professionnelles", "type": "expense", "color": "#f472b6", "sort_order": 2, "children": [] },
{ "id": 1933, "name": "Cotisations syndicales", "i18n_key": "categoriesSeed.finances.dons.syndicales", "type": "expense", "color": "#f9a8d4", "sort_order": 3, "children": [] }
]
},
{ "id": 1940, "name": "Cadeaux", "i18n_key": "categoriesSeed.finances.cadeaux", "type": "expense", "color": "#f43f5e", "sort_order": 4, "children": [] },
{ "id": 1945, "name": "Retrait cash", "i18n_key": "categoriesSeed.finances.cash", "type": "expense", "color": "#57534e", "sort_order": 5, "children": [] },
{ "id": 1946, "name": "Achats divers non catégorisés", "i18n_key": "categoriesSeed.finances.divers", "type": "expense", "color": "#78716c", "sort_order": 6, "children": [] }
]
},
{
"id": 1950,
"name": "Transferts & placements",
"i18n_key": "categoriesSeed.transferts.root",
"type": "transfer",
"color": "#0ea5e9",
"sort_order": 11,
"children": [
{
"id": 1960,
"name": "Épargne & placements",
"i18n_key": "categoriesSeed.transferts.epargne.root",
"type": "transfer",
"color": "#0ea5e9",
"sort_order": 1,
"children": [
{ "id": 1961, "name": "REER / RRSP", "i18n_key": "categoriesSeed.transferts.epargne.reer", "type": "transfer", "color": "#0ea5e9", "sort_order": 1, "children": [] },
{ "id": 1962, "name": "CELI / TFSA", "i18n_key": "categoriesSeed.transferts.epargne.celi", "type": "transfer", "color": "#0284c7", "sort_order": 2, "children": [] },
{ "id": 1963, "name": "REEE / RESP", "i18n_key": "categoriesSeed.transferts.epargne.reee", "type": "transfer", "color": "#0369a1", "sort_order": 3, "children": [] },
{ "id": 1964, "name": "Compte non-enregistré", "i18n_key": "categoriesSeed.transferts.epargne.nonEnregistre", "type": "transfer", "color": "#075985", "sort_order": 4, "children": [] },
{ "id": 1965, "name": "Fonds d'urgence", "i18n_key": "categoriesSeed.transferts.epargne.urgence", "type": "transfer", "color": "#0c4a6e", "sort_order": 5, "children": [] }
]
},
{
"id": 1970,
"name": "Remboursement de dette",
"i18n_key": "categoriesSeed.transferts.dette.root",
"type": "transfer",
"color": "#7c3aed",
"sort_order": 2,
"children": [
{ "id": 1971, "name": "Paiement carte crédit", "i18n_key": "categoriesSeed.transferts.dette.carteCredit", "type": "transfer", "color": "#7c3aed", "sort_order": 1, "children": [] },
{ "id": 1972, "name": "Remboursement prêt étudiant", "i18n_key": "categoriesSeed.transferts.dette.etudiant", "type": "transfer", "color": "#8b5cf6", "sort_order": 2, "children": [] },
{ "id": 1973, "name": "Remboursement prêt perso", "i18n_key": "categoriesSeed.transferts.dette.personnel", "type": "transfer", "color": "#a78bfa", "sort_order": 3, "children": [] }
]
},
{ "id": 1980, "name": "Transferts internes", "i18n_key": "categoriesSeed.transferts.internes", "type": "transfer", "color": "#64748b", "sort_order": 3, "children": [] }
]
}
]
}

View file

@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
defaultCartesReferencePeriod,
readCartesPeriodMode,
CARTES_PERIOD_MODE_STORAGE_KEY,
} 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,
});
});
});
describe("readCartesPeriodMode (localStorage round-trip)", () => {
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("defaults to 'month' when nothing is persisted", () => {
expect(readCartesPeriodMode()).toBe("month");
});
it("reads back a previously persisted 'month' value (round-trip)", () => {
store.set(CARTES_PERIOD_MODE_STORAGE_KEY, "month");
expect(readCartesPeriodMode()).toBe("month");
});
it("reads back a previously persisted 'ytd' value (round-trip)", () => {
store.set(CARTES_PERIOD_MODE_STORAGE_KEY, "ytd");
expect(readCartesPeriodMode()).toBe("ytd");
});
it("falls back to default for unknown/corrupted values", () => {
store.set(CARTES_PERIOD_MODE_STORAGE_KEY, "bogus");
expect(readCartesPeriodMode()).toBe("month");
});
it("supports a custom fallback", () => {
expect(readCartesPeriodMode(CARTES_PERIOD_MODE_STORAGE_KEY, "ytd")).toBe("ytd");
});
it("uses the expected storage key", () => {
expect(CARTES_PERIOD_MODE_STORAGE_KEY).toBe("reports-cartes-period-mode");
});
});

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

@ -0,0 +1,110 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CartesSnapshot, CartesKpiPeriodMode } from "../shared/types";
import { getCartesSnapshot } from "../services/reportService";
import { defaultReferencePeriod } from "../utils/referencePeriod";
export const CARTES_PERIOD_MODE_STORAGE_KEY = "reports-cartes-period-mode";
interface State {
year: number;
month: number;
mode: CartesKpiPeriodMode;
snapshot: CartesSnapshot | null;
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_MODE"; payload: CartesKpiPeriodMode }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SNAPSHOT"; payload: CartesSnapshot }
| { type: "SET_ERROR"; payload: string };
/**
* Re-exported so older imports keep working. New code should import
* `defaultReferencePeriod` from `../utils/referencePeriod`.
*/
export const defaultCartesReferencePeriod = defaultReferencePeriod;
/**
* Read the persisted period mode from localStorage. Unrecognised values fall
* back to "month" so a corrupted/legacy key never breaks the page.
*/
export function readCartesPeriodMode(
storageKey: string = CARTES_PERIOD_MODE_STORAGE_KEY,
fallback: CartesKpiPeriodMode = "month",
): CartesKpiPeriodMode {
if (typeof localStorage === "undefined") return fallback;
const saved = localStorage.getItem(storageKey);
return saved === "month" || saved === "ytd" ? saved : fallback;
}
const defaultRef = defaultReferencePeriod();
const initialState: State = {
year: defaultRef.year,
month: defaultRef.month,
mode: "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_MODE":
return { ...state, mode: action.payload };
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 [state, dispatch] = useReducer(reducer, initialState, (init) => ({
...init,
mode: readCartesPeriodMode(),
}));
const fetchIdRef = useRef(0);
const fetch = useCallback(async (year: number, month: number, mode: CartesKpiPeriodMode) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const snapshot = await getCartesSnapshot(year, month, mode);
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, state.mode);
}, [fetch, state.year, state.month, state.mode]);
const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []);
const setMode = useCallback((mode: CartesKpiPeriodMode) => {
if (typeof localStorage !== "undefined") {
localStorage.setItem(CARTES_PERIOD_MODE_STORAGE_KEY, mode);
}
dispatch({ type: "SET_MODE", payload: mode });
}, []);
return {
...state,
setReferencePeriod,
setMode,
};
}

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 });
});
});
});

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

@ -0,0 +1,147 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CategoryDelta } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod";
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
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`.
* Thin wrapper around the shared helper kept as a named export so existing
* imports (and tests) keep working.
*/
export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } {
return sharedDefaultReferencePeriod(today);
}
/**
* 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, month);
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,35 @@
import { describe, it, expect } from "vitest";
import { resolveHighlightsReference } from "./useHighlights";
describe("resolveHighlightsReference", () => {
const TODAY = new Date(2026, 3, 14); // April 14, 2026
it("falls back to the previous month when no params are provided", () => {
expect(resolveHighlightsReference(null, null, TODAY)).toEqual({ year: 2026, month: 3 });
});
it("accepts a valid (year, month) pair from the URL", () => {
expect(resolveHighlightsReference("2025", "11", TODAY)).toEqual({ year: 2025, month: 11 });
});
it("rejects non-integer values and falls back to the default", () => {
expect(resolveHighlightsReference("abc", "3", TODAY)).toEqual({ year: 2026, month: 3 });
expect(resolveHighlightsReference("2026", "foo", TODAY)).toEqual({ year: 2026, month: 3 });
});
it("rejects out-of-range months and falls back to the default", () => {
expect(resolveHighlightsReference("2026", "0", TODAY)).toEqual({ year: 2026, month: 3 });
expect(resolveHighlightsReference("2026", "13", TODAY)).toEqual({ year: 2026, month: 3 });
});
it("rejects absurd years and falls back to the default", () => {
expect(resolveHighlightsReference("999", "6", TODAY)).toEqual({ year: 2026, month: 3 });
});
it("wraps January back to December of the previous year for the default", () => {
expect(resolveHighlightsReference(null, null, new Date(2026, 0, 10))).toEqual({
year: 2025,
month: 12,
});
});
});

130
src/hooks/useHighlights.ts Normal file
View file

@ -0,0 +1,130 @@
import { useReducer, useEffect, useRef, useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type { HighlightsData } from "../shared/types";
import { getHighlights } from "../services/reportService";
import { defaultReferencePeriod } from "../utils/referencePeriod";
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;
}
}
/**
* Parses `?refY=YYYY&refM=MM` from the search string. Falls back to the
* previous-month default when either is missing or invalid. Exposed for
* unit tests.
*/
export function resolveHighlightsReference(
rawYear: string | null,
rawMonth: string | null,
today: Date = new Date(),
): { year: number; month: number } {
const y = rawYear !== null ? Number(rawYear) : NaN;
const m = rawMonth !== null ? Number(rawMonth) : NaN;
if (
Number.isInteger(y) &&
Number.isInteger(m) &&
y >= 1970 &&
y <= 9999 &&
m >= 1 &&
m <= 12
) {
return { year: y, month: m };
}
return defaultReferencePeriod(today);
}
export function useHighlights() {
const [searchParams, setSearchParams] = useSearchParams();
const rawRefY = searchParams.get("refY");
const rawRefM = searchParams.get("refM");
const { year: referenceYear, month: referenceMonth } = useMemo(
() => resolveHighlightsReference(rawRefY, rawRefM),
[rawRefY, rawRefM],
);
// YTD is always anchored on the current civil year — independent of the
// user-picked reference month.
const ytdYear = useMemo(() => new Date().getFullYear(), []);
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(
async (windowDays: 30 | 60 | 90, year: number, month: number, ytd: number) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const data = await getHighlights(year, month, ytd, windowDays);
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, referenceYear, referenceMonth, ytdYear);
}, [fetch, state.windowDays, referenceYear, referenceMonth, ytdYear]);
const setWindowDays = useCallback((d: 30 | 60 | 90) => {
dispatch({ type: "SET_WINDOW_DAYS", payload: d });
}, []);
const setReferencePeriod = useCallback(
(year: number, month: number) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set("refY", String(year));
params.set("refM", String(month));
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
return {
...state,
setWindowDays,
year: referenceYear,
month: referenceMonth,
ytdYear,
setReferencePeriod,
};
}

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

@ -358,7 +358,13 @@
"period": "Period",
"byCategory": "Expenses by Category",
"overTime": "Category Over Time",
"trends": "Monthly Trends",
"trends": {
"subviewGlobal": "Global flow",
"subviewByCategory": "By category",
"chartBar": "Bars",
"chartArea": "Stacked area",
"chartTypeAria": "Chart type"
},
"budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom",
@ -381,33 +387,106 @@
"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"
"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",
"currentAmount": "Current",
"previousAmount": "Previous",
"totalRow": "Total"
},
"cartes": {
"kpiSectionAria": "Key indicators for the reference month",
"income": "Income",
"expenses": "Expenses",
"net": "Net balance",
"savingsRate": "Savings rate",
"savingsRateTooltip": {
"month": "Formula: (income expenses) ÷ income × 100, computed on the reference month.",
"ytd": "Formula: (YTD income YTD expenses) ÷ YTD income × 100, cumulative from January 1st."
},
"periodMode": {
"month": "Monthly",
"ytd": "YTD",
"aria": "Period"
},
"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",
"searchPlaceholder": "Search 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 +494,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"
]
}
},
@ -739,32 +817,33 @@
},
"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)",
"Cartes: single-month KPI dashboard (income, expenses, net, savings rate) with a Monthly/YTD toggle, 13-month sparklines, top movers, budget adherence, and seasonality",
"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",
"On /reports/cartes, pick a reference month then toggle Monthly vs. YTD to flip the 4 KPI cards between the month and the year-to-date cumulative view — the choice is persisted",
"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",
"On /reports/cartes in YTD mode, the MoM delta for January is always \"—\" (no prior YTD window inside the same year), and the savings rate stays \"—\" when YTD income is zero",
"Seasonality, top movers, and budget adherence stay monthly even when the toggle is set to YTD — only the 4 KPI numbers change"
]
},
"settings": {
@ -777,7 +856,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",
@ -785,7 +865,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",
@ -793,7 +874,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"
]
}
},
@ -847,7 +929,9 @@
"language": "Language",
"total": "Total",
"darkMode": "Dark mode",
"lightMode": "Light mode"
"lightMode": "Light mode",
"close": "Close",
"underConstruction": "Under construction"
},
"license": {
"title": "License",
@ -882,6 +966,269 @@
"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"
"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"
}
},
"categoriesSeed": {
"revenus": {
"root": "Income",
"emploi": {
"root": "Employment",
"paie": "Regular paycheque",
"primes": "Bonuses & premiums",
"autonome": "Self-employment"
},
"gouvernemental": {
"root": "Government",
"remboursementImpot": "Tax refund",
"allocations": "Family allowances",
"credits": "GST/QST credits",
"assuranceEmploi": "Employment insurance / QPIP"
},
"placement": {
"root": "Investment income",
"interets": "Interest & dividends",
"capital": "Capital gains",
"locatifs": "Rental income"
},
"autres": "Other income"
},
"alimentation": {
"root": "Food",
"epicerie": {
"root": "Groceries & markets",
"reguliere": "Regular groceries",
"boucherie": "Butcher & fishmonger",
"boulangerie": "Bakery & pastry",
"depanneur": "Convenience store",
"marche": "Markets & specialty food"
},
"restauration": {
"root": "Dining out",
"restaurant": "Restaurant",
"cafe": "Coffee shop & quick bakery",
"fastfood": "Fast food",
"livraison": "Home delivery",
"cantine": "Cafeteria"
}
},
"logement": {
"root": "Housing",
"habitation": {
"root": "Primary residence",
"loyer": "Rent",
"hypotheque": "Mortgage",
"taxes": "Municipal & school taxes",
"copropriete": "Condo fees"
},
"services": {
"root": "Utilities",
"electricite": "Electricity",
"gaz": "Natural gas",
"chauffage": "Heating (oil, propane)",
"eau": "Water & sewer"
},
"communications": {
"root": "Communications",
"internet": "Home internet",
"mobile": "Mobile phone",
"residentielle": "Landline phone",
"streamingTv": "Cable & TV streaming"
},
"entretien": {
"root": "Maintenance & repairs",
"general": "General maintenance",
"renovations": "Renovations",
"materiaux": "Materials & tools",
"paysager": "Landscaping"
},
"assurance": "Home insurance"
},
"menage": {
"root": "Household & furnishings",
"ameublement": {
"root": "Furnishings",
"meubles": "Furniture",
"electromenagers": "Appliances",
"decoration": "Decoration"
},
"fournitures": {
"root": "Household supplies",
"entretien": "Cleaning supplies",
"literie": "Bedding & linens",
"vaisselle": "Dishes & utensils"
},
"services": {
"root": "Domestic services",
"nettoyage": "Cleaning services",
"buanderie": "Laundry & dry cleaning"
}
},
"vetements": {
"root": "Clothing & footwear",
"adultes": "Adult clothing",
"enfants": "Children's clothing",
"chaussures": "Footwear",
"accessoires": "Accessories & jewelry"
},
"transport": {
"root": "Transportation",
"vehicule": {
"root": "Personal vehicle",
"achat": "Vehicle purchase / lease",
"essence": "Gasoline",
"entretien": "Auto maintenance & repairs",
"immatriculation": "Registration & licence",
"stationnement": "Parking & tolls",
"assurance": "Auto insurance"
},
"public": {
"root": "Public transit",
"autobus": "Bus & subway",
"train": "Commuter train",
"taxi": "Taxi & ride-sharing"
},
"voyages": {
"root": "Long-distance travel",
"avion": "Air travel",
"trainAutocar": "Train & coach",
"hebergement": "Lodging",
"location": "Travel car rental"
}
},
"sante": {
"root": "Health & personal care",
"medicaux": {
"root": "Medical care",
"pharmacie": "Pharmacy",
"consultations": "Medical consultations",
"dentiste": "Dentist & orthodontist",
"optometrie": "Optometry & eyewear",
"therapies": "Therapies (physio, psych, etc.)",
"assurance": "Supplemental health insurance"
},
"personnels": {
"root": "Personal care",
"coiffure": "Haircare & esthetics",
"soins": "Personal care products"
},
"assuranceVie": "Life & disability insurance"
},
"loisirs": {
"root": "Leisure, education & reading",
"divertissement": {
"root": "Entertainment",
"cinema": "Cinema & shows",
"jeux": "Video games & consoles",
"streamingVideo": "Video streaming",
"streamingMusique": "Music & audio streaming",
"jouets": "Toys & hobbies"
},
"sports": {
"root": "Sports & outdoors",
"abonnements": "Sports memberships",
"equipement": "Sports equipment",
"parcs": "Parks & outdoor activities"
},
"formation": {
"root": "Education & training",
"scolarite": "Tuition fees",
"materiel": "School supplies",
"cours": "Courses & certifications",
"abonnements": "Professional subscriptions"
},
"lecture": {
"root": "Reading & media",
"livres": "Books",
"journaux": "Newspapers & magazines"
},
"animaux": {
"root": "Pets",
"nourriture": "Pet food & supplies",
"veterinaire": "Veterinarian"
}
},
"consommation": {
"root": "Beverages, tobacco & cannabis",
"alcool": "Alcohol (SAQ, microbreweries)",
"cannabis": "Cannabis (SQDC)",
"tabac": "Tobacco"
},
"finances": {
"root": "Finances & obligations",
"fraisBancaires": {
"root": "Bank fees",
"compte": "Account fees",
"interets": "Interest & credit fees",
"change": "Currency exchange fees"
},
"impots": {
"root": "Taxes",
"federal": "Federal income tax",
"provincial": "Provincial income tax",
"acomptes": "Instalment payments"
},
"dons": {
"root": "Donations & dues",
"charite": "Charitable donations",
"professionnelles": "Professional dues",
"syndicales": "Union dues"
},
"cadeaux": "Gifts",
"cash": "Cash withdrawal",
"divers": "Miscellaneous uncategorized purchases"
},
"transferts": {
"root": "Transfers & investments",
"epargne": {
"root": "Savings & investments",
"reer": "RRSP",
"celi": "TFSA",
"reee": "RESP",
"nonEnregistre": "Non-registered account",
"urgence": "Emergency fund"
},
"dette": {
"root": "Debt repayment",
"carteCredit": "Credit card payment",
"etudiant": "Student loan repayment",
"personnel": "Personal loan repayment"
},
"internes": "Internal transfers"
}
}
}

View file

@ -358,8 +358,14 @@
"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",
"chartBar": "Barres",
"chartArea": "Surface empilée",
"chartTypeAria": "Type de graphique"
},
"budgetVsActual": "Budget vs Réel",
"subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas",
"detail": {
@ -376,38 +382,111 @@
"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"
"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",
"currentAmount": "Courant",
"previousAmount": "Précédent",
"totalRow": "Total"
},
"cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence",
"income": "Revenus",
"expenses": "Dépenses",
"net": "Solde net",
"savingsRate": "Taux d'épargne",
"savingsRateTooltip": {
"month": "Formule : (revenus dépenses) ÷ revenus × 100, calculée sur le mois de référence.",
"ytd": "Formule : (revenus YTD dépenses YTD) ÷ revenus YTD × 100, cumul depuis le 1er janvier."
},
"periodMode": {
"month": "Mensuel",
"ytd": "Cumul annuel",
"aria": "Choix de la période"
},
"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",
"searchPlaceholder": "Rechercher 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 +494,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"
]
}
},
@ -739,32 +817,33 @@
},
"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)",
"Cartes : tableau de bord KPI d'un mois (revenus, dépenses, solde net, taux d'épargne) avec toggle Mensuel/YTD, sparklines 13 mois, top mouvements, adhésion budgétaire et saisonnalité",
"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",
"Sur /reports/cartes, choisissez un mois de référence puis basculez entre Mensuel et Cumul annuel (YTD) pour flipper les 4 cartes KPI — le choix est persisté",
"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",
"Sur /reports/cartes en mode YTD, le delta MoM du mois de janvier est toujours « — » (pas de fenêtre YTD antérieure dans la même année), et le taux d'épargne reste « — » quand les revenus YTD sont à zéro",
"La saisonnalité, les top mouvements et l'adhésion budgétaire restent mensuels même quand le toggle est sur YTD — seuls les 4 chiffres KPI changent"
]
},
"settings": {
@ -777,7 +856,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",
@ -785,7 +865,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",
@ -793,7 +874,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"
]
}
},
@ -847,7 +929,9 @@
"language": "Langue",
"total": "Total",
"darkMode": "Mode sombre",
"lightMode": "Mode clair"
"lightMode": "Mode clair",
"close": "Fermer",
"underConstruction": "En construction"
},
"license": {
"title": "Licence",
@ -882,6 +966,269 @@
"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é"
"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"
}
},
"categoriesSeed": {
"revenus": {
"root": "Revenus",
"emploi": {
"root": "Emploi",
"paie": "Paie régulière",
"primes": "Primes & bonus",
"autonome": "Travail autonome"
},
"gouvernemental": {
"root": "Gouvernemental",
"remboursementImpot": "Remboursement impôt",
"allocations": "Allocations familiales",
"credits": "Crédits TPS/TVQ",
"assuranceEmploi": "Assurance-emploi / RQAP"
},
"placement": {
"root": "Revenus de placement",
"interets": "Intérêts & dividendes",
"capital": "Gains en capital",
"locatifs": "Revenus locatifs"
},
"autres": "Autres revenus"
},
"alimentation": {
"root": "Alimentation",
"epicerie": {
"root": "Épicerie & marché",
"reguliere": "Épicerie régulière",
"boucherie": "Boucherie & poissonnerie",
"boulangerie": "Boulangerie & pâtisserie",
"depanneur": "Dépanneur",
"marche": "Marché & produits spécialisés"
},
"restauration": {
"root": "Restauration",
"restaurant": "Restaurant",
"cafe": "Café & boulangerie rapide",
"fastfood": "Restauration rapide",
"livraison": "Livraison à domicile",
"cantine": "Cantine & cafétéria"
}
},
"logement": {
"root": "Logement",
"habitation": {
"root": "Habitation principale",
"loyer": "Loyer",
"hypotheque": "Hypothèque",
"taxes": "Taxes municipales & scolaires",
"copropriete": "Charges de copropriété"
},
"services": {
"root": "Services publics",
"electricite": "Électricité",
"gaz": "Gaz naturel",
"chauffage": "Chauffage (mazout, propane)",
"eau": "Eau & égouts"
},
"communications": {
"root": "Communications",
"internet": "Internet résidentiel",
"mobile": "Téléphonie mobile",
"residentielle": "Téléphonie résidentielle",
"streamingTv": "Câblodistribution & streaming TV"
},
"entretien": {
"root": "Entretien & réparations",
"general": "Entretien général",
"renovations": "Rénovations",
"materiaux": "Matériaux & outils",
"paysager": "Aménagement paysager"
},
"assurance": "Assurance habitation"
},
"menage": {
"root": "Ménage & ameublement",
"ameublement": {
"root": "Ameublement",
"meubles": "Meubles",
"electromenagers": "Électroménagers",
"decoration": "Décoration"
},
"fournitures": {
"root": "Fournitures ménagères",
"entretien": "Produits d'entretien",
"literie": "Literie & linge de maison",
"vaisselle": "Vaisselle & ustensiles"
},
"services": {
"root": "Services domestiques",
"nettoyage": "Ménage & nettoyage",
"buanderie": "Buanderie & pressing"
}
},
"vetements": {
"root": "Vêtements & chaussures",
"adultes": "Vêtements adultes",
"enfants": "Vêtements enfants",
"chaussures": "Chaussures",
"accessoires": "Accessoires & bijoux"
},
"transport": {
"root": "Transport",
"vehicule": {
"root": "Véhicule personnel",
"achat": "Achat / location véhicule",
"essence": "Essence",
"entretien": "Entretien & réparations auto",
"immatriculation": "Immatriculation & permis",
"stationnement": "Stationnement & péages",
"assurance": "Assurance auto"
},
"public": {
"root": "Transport public",
"autobus": "Autobus & métro",
"train": "Train de banlieue",
"taxi": "Taxi & covoiturage"
},
"voyages": {
"root": "Voyages longue distance",
"avion": "Avion",
"trainAutocar": "Train & autocar",
"hebergement": "Hébergement",
"location": "Location véhicule voyage"
}
},
"sante": {
"root": "Santé & soins personnels",
"medicaux": {
"root": "Soins médicaux",
"pharmacie": "Pharmacie",
"consultations": "Consultations médicales",
"dentiste": "Dentiste & orthodontiste",
"optometrie": "Optométrie & lunettes",
"therapies": "Thérapies (physio, psycho, etc.)",
"assurance": "Assurance santé complémentaire"
},
"personnels": {
"root": "Soins personnels",
"coiffure": "Coiffure & esthétique",
"soins": "Produits de soins corporels"
},
"assuranceVie": "Assurance vie & invalidité"
},
"loisirs": {
"root": "Loisirs, formation & lecture",
"divertissement": {
"root": "Divertissement",
"cinema": "Cinéma & spectacles",
"jeux": "Jeux vidéo & consoles",
"streamingVideo": "Streaming vidéo",
"streamingMusique": "Streaming musique & audio",
"jouets": "Jouets & passe-temps"
},
"sports": {
"root": "Sports & plein air",
"abonnements": "Abonnements sportifs",
"equipement": "Équipement sportif",
"parcs": "Parcs & activités plein air"
},
"formation": {
"root": "Formation & éducation",
"scolarite": "Scolarité (frais)",
"materiel": "Matériel scolaire",
"cours": "Cours & certifications",
"abonnements": "Abonnements professionnels"
},
"lecture": {
"root": "Lecture & médias",
"livres": "Livres",
"journaux": "Journaux & magazines"
},
"animaux": {
"root": "Animaux de compagnie",
"nourriture": "Nourriture & accessoires animaux",
"veterinaire": "Vétérinaire"
}
},
"consommation": {
"root": "Boissons, tabac & cannabis",
"alcool": "Alcool (SAQ, microbrasseries)",
"cannabis": "Cannabis (SQDC)",
"tabac": "Tabac"
},
"finances": {
"root": "Finances & obligations",
"fraisBancaires": {
"root": "Frais bancaires",
"compte": "Frais de compte",
"interets": "Intérêts & frais de crédit",
"change": "Frais de change"
},
"impots": {
"root": "Impôts & taxes",
"federal": "Impôt fédéral",
"provincial": "Impôt provincial",
"acomptes": "Acomptes provisionnels"
},
"dons": {
"root": "Dons & cotisations",
"charite": "Dons de charité",
"professionnelles": "Cotisations professionnelles",
"syndicales": "Cotisations syndicales"
},
"cadeaux": "Cadeaux",
"cash": "Retrait cash",
"divers": "Achats divers non catégorisés"
},
"transferts": {
"root": "Transferts & placements",
"epargne": {
"root": "Épargne & placements",
"reer": "REER",
"celi": "CELI",
"reee": "REEE",
"nonEnregistre": "Compte non-enregistré",
"urgence": "Fonds d'urgence"
},
"dette": {
"root": "Remboursement de dette",
"carteCredit": "Paiement carte crédit",
"etudiant": "Remboursement prêt étudiant",
"personnel": "Remboursement prêt perso"
},
"internes": "Transferts internes"
}
}
}

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,117 @@
// The Cartes report is intentionally a "month X vs X-1 vs X-12" snapshot, so
// only a reference-month picker is surfaced here — a generic date-range
// selector has no meaning on this sub-report (see issue #101).
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
import CartesPeriodModeToggle from "../components/reports/cards/CartesPeriodModeToggle";
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, mode, snapshot, isLoading, error, setReferencePeriod, setMode } =
useCartes();
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
// The savings-rate tooltip copy depends on the active period mode so users
// always see the formula that matches the number currently on screen. The
// i18n key is a nested object (month / ytd) — suffixing keeps the two
// variants side by side in the locale files.
const savingsRateTooltip = t(
mode === "ytd"
? "reports.cartes.savingsRateTooltip.ytd"
: "reports.cartes.savingsRateTooltip.month",
);
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-end gap-3 mb-6 flex-wrap">
<CartesPeriodModeToggle value={mode} onChange={setMode} />
<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}
tooltip={savingsRateTooltip}
/>
</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,134 @@
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);
// Monthly block labels: a specific month on both sides. For MoM the
// previous = previous month of same year; for YoY the previous = same
// month one year earlier (comparisonMeta already resolves both).
const currentLabel = formatMonthLabel(year, month, i18n.language);
const previousLabel = formatMonthLabel(previousYear, prevMonth, i18n.language);
// Cumulative YTD block labels. For MoM both sides live in the same year
// but the previous side only covers up to the end of the previous month,
// so we surface the end-of-window month label on each side. For YoY we
// compare Jan→refMonth across two different years; the year + month label
// makes the window boundary unambiguous.
const cumulativeCurrentLabel =
subMode === "mom"
? `${formatMonthLabel(year, month, i18n.language)}`
: `${String(year)}${formatMonthLabel(year, month, i18n.language)}`;
const cumulativePreviousLabel =
subMode === "mom"
? `${formatMonthLabel(previousYear, prevMonth, i18n.language)}`
: `${String(previousYear)}${formatMonthLabel(previousYear, prevMonth, i18n.language)}`;
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}
cumulativePreviousLabel={cumulativePreviousLabel}
cumulativeCurrentLabel={cumulativeCurrentLabel}
/>
)}
</div>
);
}

View file

@ -0,0 +1,132 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft, Tag } from "lucide-react";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
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 type { RecentTransaction } from "../shared/types";
const STORAGE_KEY = "reports-viewmode-highlights";
export default function ReportsHighlightsPage() {
const { t } = useTranslation();
const {
data,
isLoading,
error,
windowDays,
setWindowDays,
year,
month,
setReferencePeriod,
} = 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 flex-wrap">
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
<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}
value={period}
onChange={setPeriod}
customDateFrom={state.customDateFrom}
customDateTo={state.customDateTo}
customDateFrom={from}
customDateTo={to}
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>
<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} />
))}
{["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)}
/>
)}
</section>
</div>
);
}

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