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 }; }