feat: global time range context with custom date picker

Lift time range state into a shared React context so the selected
range persists across page navigation and browser refreshes
(localStorage). Add a "Custom" option with a popover date picker
that lets users specify arbitrary start/end dates. All preset end
times now use endOf("day") (23:59:59) instead of the current moment.
This commit is contained in:
2026-04-07 14:49:58 +08:00
parent 004fd37622
commit 9bb36432ba
10 changed files with 280 additions and 55 deletions

View File

@@ -26,6 +26,10 @@ const translations = {
"time.7d": "7 天",
"time.30d": "30 天",
"time.all": "全部",
"time.custom": "自定义",
"time.startDate": "开始日期",
"time.endDate": "结束日期",
"time.confirm": "确认",
// granularity
"gran.day": "日",
"gran.week": "周",
@@ -108,6 +112,10 @@ const translations = {
"time.7d": "7 Days",
"time.30d": "30 Days",
"time.all": "All",
"time.custom": "Custom",
"time.startDate": "Start",
"time.endDate": "End",
"time.confirm": "Confirm",
"gran.day": "Day",
"gran.week": "Week",
"gran.month": "Month",

View File

@@ -0,0 +1,93 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import dayjs from "dayjs";
import { type TimeRange, getTimeRange } 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: "30d",
setRange: () => {},
customStart: "",
customEnd: "",
setCustomStart: () => {},
setCustomEnd: () => {},
getEffectiveRange: () => ({}),
});
function loadSaved(): { range: TimeRange; customStart: string; customEnd: string } | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem("time-range");
if (!raw) return null;
const parsed = JSON.parse(raw);
return {
range: parsed.range || "30d",
customStart: parsed.customStart || "",
customEnd: parsed.customEnd || "",
};
} catch {
return null;
}
}
function persist(range: TimeRange, customStart: string, customEnd: string) {
localStorage.setItem("time-range", JSON.stringify({ range, customStart, customEnd }));
}
export function TimeRangeProvider({ children }: { children: ReactNode }) {
const [range, setRangeState] = useState<TimeRange>(() => loadSaved()?.range ?? "30d");
const [customStart, setCustomStartState] = useState(() => loadSaved()?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD"));
const [customEnd, setCustomEndState] = useState(() => loadSaved()?.customEnd || dayjs().format("YYYY-MM-DD"));
const setRange = useCallback((r: TimeRange) => {
setRangeState(r);
setCustomStartState(prev => { persist(r, prev, customEnd); return prev; });
}, [customEnd]);
const setCustomStart = useCallback((s: string) => {
setCustomStartState(s);
persist(range, s, customEnd);
}, [range, customEnd]);
const setCustomEnd = useCallback((e: string) => {
setCustomEndState(e);
persist(range, customStart, e);
}, [range, customStart]);
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);
}, [range, customStart, customEnd]);
return (
<TimeRangeContext.Provider value={{
range, setRange,
customStart, customEnd,
setCustomStart,
setCustomEnd,
getEffectiveRange,
}}>
{children}
</TimeRangeContext.Provider>
);
}
export function useTimeRange() {
return useContext(TimeRangeContext);
}

View File

@@ -27,13 +27,14 @@ export type TimeRange = "today" | "7d" | "30d" | "all" | "custom";
export function getTimeRange(range: TimeRange): { start?: number; end?: number } {
const now = dayjs();
const end = now.endOf("day").unix();
switch (range) {
case "today":
return { start: now.startOf("day").unix(), end: now.unix() };
return { start: now.startOf("day").unix(), end };
case "7d":
return { start: now.subtract(7, "day").startOf("day").unix(), end: now.unix() };
return { start: now.subtract(7, "day").startOf("day").unix(), end };
case "30d":
return { start: now.subtract(30, "day").startOf("day").unix(), end: now.unix() };
return { start: now.subtract(30, "day").startOf("day").unix(), end };
case "all":
return {};
default: