- 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>
6.8 KiB
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:
- 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.
- 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. - 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:
- Validates the keyword is 2–64 characters after trim (anti-ReDoS, CWE-1333).
- Previews matching transactions via a parameterised
LIKE $1query, then filters in memory with the existingbuildKeywordRegexhelper (anti-SQL-injection, CWE-89). - Caps the visible preview at 50 rows; an explicit opt-in checkbox lets the user extend the apply to N−50 non-displayed matches.
- 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). - Renders transaction descriptions as React children — never
dangerouslySetInnerHTML— with CSS-only truncation (CWE-79). - 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
ContextMenuimplementation shared across reports and charts. Chose to generalise the existingChartContextMenuinto aContextMenushell;ChartContextMenunow 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)