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>
182 lines
5.8 KiB
TypeScript
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 };
|
|
}
|