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
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, useSyncExternalStore, type ReactNode } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { type TimeRange, getTimeRange } from "@/lib/utils";
|
||||
import { type TimeRange } from "@/lib/utils";
|
||||
|
||||
interface TimeRangeContextType {
|
||||
range: TimeRange;
|
||||
@@ -16,7 +16,7 @@ interface TimeRangeContextType {
|
||||
}
|
||||
|
||||
const TimeRangeContext = createContext<TimeRangeContextType>({
|
||||
range: "30d",
|
||||
range: "7d",
|
||||
setRange: () => {},
|
||||
customStart: "",
|
||||
customEnd: "",
|
||||
@@ -25,9 +25,8 @@ const TimeRangeContext = createContext<TimeRangeContextType>({
|
||||
getEffectiveRange: () => ({}),
|
||||
});
|
||||
|
||||
// Use useSyncExternalStore to safely read localStorage without hydration mismatch
|
||||
const STORAGE_KEY = "time-range";
|
||||
const DEFAULT_RANGE = "30d";
|
||||
const DEFAULT_RANGE: TimeRange = "7d";
|
||||
|
||||
let listeners: Array<() => void> = [];
|
||||
function subscribe(cb: () => void) {
|
||||
@@ -50,6 +49,30 @@ function persist(range: TimeRange, customStart: string, customEnd: string) {
|
||||
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);
|
||||
|
||||
@@ -58,46 +81,70 @@ export function TimeRangeProvider({ children }: { children: ReactNode }) {
|
||||
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(
|
||||
saved?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD")
|
||||
);
|
||||
const [customEnd, setCustomEndState] = useState(
|
||||
saved?.customEnd || dayjs().format("YYYY-MM-DD")
|
||||
);
|
||||
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);
|
||||
setCustomStartState((prev: string) => {
|
||||
setCustomEndState((end: string) => { persist(r, prev, end); return end; });
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
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);
|
||||
setRangeState((r: TimeRange) => {
|
||||
setCustomEndState((end: string) => { persist(r, s, end); return end; });
|
||||
return r;
|
||||
});
|
||||
persist(rangeRef.current, s, customEndRef.current);
|
||||
}, []);
|
||||
|
||||
const setCustomEnd = useCallback((e: string) => {
|
||||
setCustomEndState(e);
|
||||
setRangeState((r: TimeRange) => {
|
||||
setCustomStartState((start: string) => { persist(r, start, e); return start; });
|
||||
return r;
|
||||
});
|
||||
persist(rangeRef.current, customStartRef.current, e);
|
||||
}, []);
|
||||
|
||||
// Always derive timestamps from customStart/customEnd — single source of truth
|
||||
const getEffectiveRange = useCallback(() => {
|
||||
if (range === "custom") {
|
||||
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;
|
||||
}
|
||||
return getTimeRange(range);
|
||||
// "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 (
|
||||
|
||||
Reference in New Issue
Block a user