import { useEffect, useMemo, useRef, useState } from "react"; export type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom"; export const PRESET_LABELS: Record = { mtd: "Month to Date", "7d": "Last 7 Days", "30d": "Last 30 Days", ytd: "Year to Date", all: "All Time", custom: "Custom", }; export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"]; // note: computeRange is called inside a useMemo that re-evaluates once per minute // (driven by minuteTick). this means sliding windows (7d, 30d) advance their upper // bound at most once per minute — acceptable for a cost dashboard. function computeRange(preset: DatePreset): { from: string; to: string } { const now = new Date(); const to = now.toISOString(); switch (preset) { case "mtd": { const d = new Date(now.getFullYear(), now.getMonth(), 1); return { from: d.toISOString(), to }; } case "7d": { const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0, 0); return { from: d.toISOString(), to }; } case "30d": { const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0, 0); return { from: d.toISOString(), to }; } case "ytd": { const d = new Date(now.getFullYear(), 0, 1); return { from: d.toISOString(), to }; } case "all": case "custom": return { from: "", to: "" }; } } // floor a Date to the nearest minute so the query key is stable across // 30s refetch ticks (prevents new cache entries on every poll cycle) function floorToMinute(d: Date): string { const floored = new Date(d); floored.setSeconds(0, 0); return floored.toISOString(); } export interface UseDateRangeResult { preset: DatePreset; setPreset: (p: DatePreset) => void; customFrom: string; setCustomFrom: (v: string) => void; customTo: string; setCustomTo: (v: string) => void; /** resolved iso strings ready to pass to api calls; empty string means unbounded */ from: string; to: string; /** false when preset=custom but both dates are not yet selected */ customReady: boolean; } export function useDateRange(): UseDateRangeResult { const [preset, setPreset] = useState("mtd"); const [customFrom, setCustomFrom] = useState(""); const [customTo, setCustomTo] = useState(""); // tick at the next calendar minute boundary, then every 60s, so sliding presets // (7d, 30d) advance their upper bound in sync with wall clock minutes rather than // drifting by the mount offset. const intervalRef = useRef | null>(null); const [minuteTick, setMinuteTick] = useState(() => floorToMinute(new Date())); useEffect(() => { const now = new Date(); const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); const timeout = setTimeout(() => { setMinuteTick(floorToMinute(new Date())); intervalRef.current = setInterval( () => setMinuteTick(floorToMinute(new Date())), 60_000, ); }, msToNextMinute); return () => { clearTimeout(timeout); if (intervalRef.current != null) clearInterval(intervalRef.current); }; }, []); const { from, to } = useMemo(() => { if (preset !== "custom") return computeRange(preset); // treat custom date strings as local-date boundaries so the full day is included // regardless of the user's timezone. "from" starts at local midnight, "to" at 23:59:59.999. const fromDate = customFrom ? new Date(customFrom + "T00:00:00") : null; const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null; return { from: fromDate ? fromDate.toISOString() : "", to: toDate ? toDate.toISOString() : "", }; // minuteTick drives re-evaluation of sliding presets once per minute. // eslint-disable-next-line react-hooks/exhaustive-deps }, [preset, customFrom, customTo, minuteTick]); const customReady = preset !== "custom" || (!!customFrom && !!customTo); return { preset, setPreset, customFrom, setCustomFrom, customTo, setCustomTo, from, to, customReady, }; }