Files
new-api-analytics/lib/time-range-context.tsx
shangzy 13805a47be fix: refactor time range to single source of truth with correct dates
- Default range changed from 30d to 7d
- Presets (today/7d/30d) now directly set customStart/customEnd dates,
  eliminating duplicate getTimeRange() calculation
- "All" preset fetches actual data boundaries from /api/date-range
  and backfills the custom date picker
- Clicking "custom" opens popover without triggering data refresh;
  only confirm applies changes
- SQL trend dates cast to ::text to avoid pg driver Date timezone offset
- Fix created_at filter from < to <= for end timestamp
2026-04-07 16:22:18 +08:00

166 lines
5.2 KiB
TypeScript

"use client";
import { createContext, useContext, useState, useCallback, useRef, useEffect, useSyncExternalStore, type ReactNode } from "react";
import dayjs from "dayjs";
import { type TimeRange } from "@/lib/utils";
interface TimeRangeContextType {
range: TimeRange;
setRange: (r: TimeRange) => void;
customStart: string;
customEnd: string;
setCustomStart: (s: string) => void;
setCustomEnd: (s: string) => void;
/** Returns { start?, end? } unix timestamps (seconds) ready for API calls */
getEffectiveRange: () => { start?: number; end?: number };
}
const TimeRangeContext = createContext<TimeRangeContextType>({
range: "7d",
setRange: () => {},
customStart: "",
customEnd: "",
setCustomStart: () => {},
setCustomEnd: () => {},
getEffectiveRange: () => ({}),
});
const STORAGE_KEY = "time-range";
const DEFAULT_RANGE: TimeRange = "7d";
let listeners: Array<() => void> = [];
function subscribe(cb: () => void) {
listeners = [...listeners, cb];
return () => { listeners = listeners.filter(l => l !== cb); };
}
function emitChange() {
for (const l of listeners) l();
}
function getSnapshot(): string {
return localStorage.getItem(STORAGE_KEY) ?? "";
}
function getServerSnapshot(): string {
return "";
}
function persist(range: TimeRange, customStart: string, customEnd: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ range, customStart, customEnd }));
emitChange();
}
/** Convert a preset to concrete date strings */
function presetDates(preset: TimeRange): { start: string; end: string } {
const today = dayjs().format("YYYY-MM-DD");
switch (preset) {
case "today":
return { start: today, end: today };
case "7d":
return { start: dayjs().subtract(6, "day").format("YYYY-MM-DD"), end: today };
case "30d":
return { start: dayjs().subtract(29, "day").format("YYYY-MM-DD"), end: today };
default:
return { start: today, end: today };
}
}
function initDates(saved: { range?: TimeRange; customStart?: string; customEnd?: string } | null) {
if (saved?.customStart && saved?.customEnd) {
return { start: saved.customStart, end: saved.customEnd };
}
const r = (saved?.range as TimeRange) ?? DEFAULT_RANGE;
if (r === "all") return { start: "", end: "" };
return presetDates(r);
}
export function TimeRangeProvider({ children }: { children: ReactNode }) {
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const saved = (() => {
if (!raw) return null;
try { return JSON.parse(raw); } catch { return null; }
})();
const init = initDates(saved);
const [range, setRangeState] = useState<TimeRange>(saved?.range ?? DEFAULT_RANGE);
const [customStart, setCustomStartState] = useState(init.start);
const [customEnd, setCustomEndState] = useState(init.end);
const rangeRef = useRef(range);
const customStartRef = useRef(customStart);
const customEndRef = useRef(customEnd);
useEffect(() => { rangeRef.current = range; }, [range]);
useEffect(() => { customStartRef.current = customStart; }, [customStart]);
useEffect(() => { customEndRef.current = customEnd; }, [customEnd]);
// Fetch actual date boundaries when "all" is selected (including on mount)
const fetchDateRange = useCallback(async () => {
try {
const res = await fetch("/api/date-range");
const { minDate, maxDate } = await res.json();
if (minDate && maxDate) {
setCustomStartState(minDate);
setCustomEndState(maxDate);
persist("all", minDate, maxDate);
}
} catch { /* ignore */ }
}, []);
// On mount: if saved range is "all", fetch real boundaries
useEffect(() => {
if (range === "all") fetchDateRange();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setRange = useCallback((r: TimeRange) => {
setRangeState(r);
if (r === "all") {
// Query first, then backfill dates
fetchDateRange();
} else if (r !== "custom") {
const { start, end } = presetDates(r);
setCustomStartState(start);
setCustomEndState(end);
persist(r, start, end);
} else {
persist(r, customStartRef.current, customEndRef.current);
}
}, [fetchDateRange]);
const setCustomStart = useCallback((s: string) => {
setCustomStartState(s);
persist(rangeRef.current, s, customEndRef.current);
}, []);
const setCustomEnd = useCallback((e: string) => {
setCustomEndState(e);
persist(rangeRef.current, customStartRef.current, e);
}, []);
// Always derive timestamps from customStart/customEnd — single source of truth
const getEffectiveRange = useCallback(() => {
// "all" with dates not yet loaded → no filter
if (range === "all" && !customStart && !customEnd) return {};
const result: { start?: number; end?: number } = {};
if (customStart) result.start = dayjs(customStart).startOf("day").unix();
if (customEnd) result.end = dayjs(customEnd).endOf("day").unix();
return result;
}, [range, customStart, customEnd]);
return (
<TimeRangeContext.Provider value={{
range, setRange,
customStart, customEnd,
setCustomStart,
setCustomEnd,
getEffectiveRange,
}}>
{children}
</TimeRangeContext.Provider>
);
}
export function useTimeRange() {
return useContext(TimeRangeContext);
}