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) <noreply@anthropic.com>
This commit is contained in:
parent
a8e3775b8b
commit
65be6c7482
6 changed files with 155 additions and 6 deletions
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue