From 65be6c7482f323759a95a509e77c040f21f26fe3 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 11 Jul 2026 15:01:43 -0400 Subject: [PATCH] feat(reports): extend useReportsPeriod with account filter foundation Add accountIds: number[] to the shared useReportsPeriod hook, URL-backed via a dedicated `sources` query param (comma-separated, bookmarkable like the existing period/from/to). Purely additive: the hook's existing shape is unchanged, so all 6 consumers (useTrends, useCompare, useCategoryZoom, ReportsCategoryPage, ReportsPage, ReportsComparePage) keep compiling as-is. Also adds the shared ReportFilters { period, accountIds } type for follow-up issues to consume, and exports pure parseAccountIds/ serializeAccountIds helpers (same hookless-testability pattern as resolveReportsPeriod). URL parsing validates each token as a finite integer via a strict regex + Number.isSafeInteger, dropping invalid tokens individually rather than discarding the whole selection. This is foundation only: no UI control exposes the filter yet, and no service reads accountIds yet (both land in follow-up issues of the "rapports uniformes" epic). Resolves #272 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + docs/architecture.md | 2 +- src/hooks/useReportsPeriod.test.ts | 75 +++++++++++++++++++++++++++++- src/hooks/useReportsPeriod.ts | 71 ++++++++++++++++++++++++++-- src/shared/types/index.ts | 11 +++++ 6 files changed, 155 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index d74b17a..be402ba 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -6,6 +6,7 @@ - Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254). - Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265). +- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272). ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index 56bcd33..9e96d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254). - Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265). +- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272). ### Fixed diff --git a/docs/architecture.md b/docs/architecture.md index d80e667..b0a628b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -206,7 +206,7 @@ Chaque hook encapsule la logique d'état via `useReducer` : | `useAdjustments` | Ajustements | | `useBudget` | Budget | | `useDashboard` | Métriques du tableau de bord | -| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) | +| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272, pas encore branché sur les services ni exposé dans une UI) | | `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) | diff --git a/src/hooks/useReportsPeriod.test.ts b/src/hooks/useReportsPeriod.test.ts index f17fd01..1597c27 100644 --- a/src/hooks/useReportsPeriod.test.ts +++ b/src/hooks/useReportsPeriod.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { resolveReportsPeriod } from "./useReportsPeriod"; +import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } from "./useReportsPeriod"; describe("resolveReportsPeriod", () => { const fixedToday = new Date("2026-04-14T12:00:00Z"); @@ -51,3 +51,76 @@ describe("resolveReportsPeriod", () => { expect(result.to).toBe("2026-12-31"); }); }); + +describe("parseAccountIds", () => { + it("defaults to an empty array (no filter) when the sources param is absent", () => { + expect(parseAccountIds(null)).toEqual([]); + }); + + it("defaults to an empty array for an empty string", () => { + expect(parseAccountIds("")).toEqual([]); + }); + + it("parses a comma-separated list of ids", () => { + expect(parseAccountIds("3,7,12")).toEqual([3, 7, 12]); + }); + + it("trims whitespace around tokens", () => { + expect(parseAccountIds(" 3 , 7 ,12")).toEqual([3, 7, 12]); + }); + + it("parses a single id", () => { + expect(parseAccountIds("42")).toEqual([42]); + }); + + it("drops non-numeric tokens but keeps the valid ones (rejects non-numeric)", () => { + expect(parseAccountIds("3,abc,7")).toEqual([3, 7]); + }); + + it("drops decimal tokens (not a finite integer)", () => { + expect(parseAccountIds("3,4.5,7")).toEqual([3, 7]); + }); + + it("drops empty tokens produced by stray/trailing commas", () => { + expect(parseAccountIds("3,,7,")).toEqual([3, 7]); + }); + + it("drops exponential/hex-looking tokens that Number() would otherwise coerce", () => { + expect(parseAccountIds("3,1e3,0x10,7")).toEqual([3, 7]); + }); + + it("drops unsafe-integer tokens (too large to be a reliable id)", () => { + expect(parseAccountIds("3,99999999999999999999,7")).toEqual([3, 7]); + }); + + it("returns an empty array when every token is invalid", () => { + expect(parseAccountIds("abc,def")).toEqual([]); + }); +}); + +describe("serializeAccountIds", () => { + it("joins ids into a comma-separated string", () => { + expect(serializeAccountIds([3, 7, 12])).toBe("3,7,12"); + }); + + it("serializes a single id", () => { + expect(serializeAccountIds([42])).toBe("42"); + }); + + it("returns null for an empty array so the param is removed (no filter)", () => { + expect(serializeAccountIds([])).toBeNull(); + }); + + it("drops invalid entries (e.g. NaN) before serializing", () => { + expect(serializeAccountIds([3, NaN, 7])).toBe("3,7"); + }); + + it("returns null when every entry is invalid", () => { + expect(serializeAccountIds([NaN, Infinity])).toBeNull(); + }); + + it("round-trips through parseAccountIds", () => { + const ids = [1, 2, 3]; + expect(parseAccountIds(serializeAccountIds(ids))).toEqual(ids); + }); +}); diff --git a/src/hooks/useReportsPeriod.ts b/src/hooks/useReportsPeriod.ts index aa1ae17..c1e9d52 100644 --- a/src/hooks/useReportsPeriod.ts +++ b/src/hooks/useReportsPeriod.ts @@ -26,6 +26,43 @@ function currentYearRange(today: Date = new Date()): { from: string; to: string return { from: `${year}-01-01`, to: `${year}-12-31` }; } +// Matches a bare (optionally negative) integer token, e.g. "3" or "-3", but +// not "3.5", "abc", "" or "1e3" — deliberately stricter than `Number(...)` so +// that hex/exponential-notation strings from a hand-edited URL don't sneak +// through as valid ids. +const INTEGER_TOKEN = /^-?\d+$/; + +/** + * Pure parser for the `sources` query param (comma-separated account/import + * source ids), exposed for the same testability reason as + * `resolveReportsPeriod`. Invalid tokens (non-numeric, decimal, empty from a + * stray comma, or too large to be a safe integer) are dropped individually + * rather than invalidating the whole list — a single corrupted token in a + * bookmarked/hand-edited URL should not silently discard the rest of an + * otherwise valid selection. Missing param or no valid token → `[]`, which + * means "no filter" (all accounts). + */ +export function parseAccountIds(raw: string | null): number[] { + if (!raw) return []; + return raw + .split(",") + .map((token) => token.trim()) + .filter((token) => INTEGER_TOKEN.test(token)) + .map(Number) + .filter((n) => Number.isSafeInteger(n)); +} + +/** + * Inverse of `parseAccountIds`: serializes account ids back to the `sources` + * query param value, or `null` when the param should be removed from the URL + * entirely (empty/all-invalid selection = no filter — mirrors how `from`/`to` + * are omitted for a non-custom period). + */ +export function serializeAccountIds(accountIds: number[]): string | null { + const valid = accountIds.filter((n) => Number.isSafeInteger(n)); + return valid.length > 0 ? valid.join(",") : null; +} + /** * Pure resolver used by the hook and unit tests. Exposed to keep the core * logic hookless and testable without rendering a router. @@ -57,15 +94,19 @@ export interface UseReportsPeriodResult { from: string; to: string; period: DashboardPeriod; + accountIds: number[]; setPeriod: (period: DashboardPeriod) => void; setCustomDates: (from: string, to: string) => void; + setAccountIds: (accountIds: number[]) => void; } /** - * Reads/writes the active reporting period via the URL query string so it is - * bookmarkable and shared across the four report sub-routes. + * Reads/writes the active reporting period — and, additively, the account + * (import source) filter — via the URL query string so both are bookmarkable + * and shared across the report sub-routes. * - * Defaults to the current civil year (Jan 1 → Dec 31). + * Defaults to the current civil year (Jan 1 → Dec 31) with no account filter + * (`accountIds: []`, meaning all accounts). */ export function useReportsPeriod(): UseReportsPeriodResult { const [searchParams, setSearchParams] = useSearchParams(); @@ -73,12 +114,15 @@ export function useReportsPeriod(): UseReportsPeriodResult { const rawPeriod = searchParams.get("period"); const rawFrom = searchParams.get("from"); const rawTo = searchParams.get("to"); + const rawSources = searchParams.get("sources"); const { from, to, period } = useMemo( () => resolveReportsPeriod(rawFrom, rawTo, rawPeriod), [rawPeriod, rawFrom, rawTo], ); + const accountIds = useMemo(() => parseAccountIds(rawSources), [rawSources]); + const setPeriod = useCallback( (next: DashboardPeriod) => { setSearchParams( @@ -115,5 +159,24 @@ export function useReportsPeriod(): UseReportsPeriodResult { [setSearchParams], ); - return { from, to, period, setPeriod, setCustomDates }; + const setAccountIds = useCallback( + (nextAccountIds: number[]) => { + setSearchParams( + (prev) => { + const params = new URLSearchParams(prev); + const serialized = serializeAccountIds(nextAccountIds); + if (serialized === null) { + params.delete("sources"); + } else { + params.set("sources", serialized); + } + return params; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + return { from, to, period, accountIds, setPeriod, setCustomDates, setAccountIds }; } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 15e4821..4297d78 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -285,6 +285,17 @@ export interface RecentTransaction { export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual"; +/** + * Shared filter state for the report pages: the active period plus the + * account (import source) subset. `accountIds` is a list of `ImportSource.id` + * — not to be confused with the Bilan module's `balance_accounts` ids, a + * disjoint id space. An empty list means "no filter" (all accounts). + */ +export interface ReportFilters { + period: DashboardPeriod; + accountIds: number[]; +} + export interface CategoryDelta { categoryId: number | null; categoryName: string;