Simpl-Resultat/src/hooks/useReportsPeriod.ts
le king fu 65be6c7482
All checks were successful
PR Check / rust (pull_request) Successful in 21m28s
PR Check / frontend (pull_request) Successful in 2m22s
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>
2026-07-11 15:01:43 -04:00

182 lines
5.8 KiB
TypeScript

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` };
}
// 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.
*/
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;
accountIds: number[];
setPeriod: (period: DashboardPeriod) => void;
setCustomDates: (from: string, to: string) => void;
setAccountIds: (accountIds: number[]) => void;
}
/**
* 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) with no account filter
* (`accountIds: []`, meaning all accounts).
*/
export function useReportsPeriod(): UseReportsPeriodResult {
const [searchParams, setSearchParams] = useSearchParams();
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(
(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],
);
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 };
}