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

@@ -4,7 +4,8 @@ import { useEffect, useState, useCallback } from "react";
import { motion } from "motion/react";
import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils";
import { buildQuery, formatNumber, formatTokens } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n";
interface AggItem {
@@ -16,7 +17,7 @@ type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens";
export default function AggregationPage() {
const { t } = useI18n();
const [range, setRange] = useState<TimeRange>("30d");
const { getEffectiveRange } = useTimeRange();
const [data, setData] = useState<AggItem[]>([]);
const [loading, setLoading] = useState(true);
const [sortKey, setSortKey] = useState<SortKey>("total_tokens");
@@ -24,11 +25,11 @@ export default function AggregationPage() {
const fetchData = useCallback(async () => {
setLoading(true);
const { start, end } = getTimeRange(range);
const { start, end } = getEffectiveRange();
const res = await fetch(buildQuery("/api/aggregation", { start, end }));
setData(await res.json());
setLoading(false);
}, [range]);
}, [getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -68,7 +69,7 @@ export default function AggregationPage() {
<Users className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
<h1 className="text-2xl font-bold gradient-text">{t("agg.title")}</h1>
</motion.div>
<TimeRangeSelector value={range} onChange={setRange} />
<TimeRangeSelector />
</div>
{!loading && data.length > 0 && (

View File

@@ -8,7 +8,8 @@ import Link from "next/link";
import { StatsCard } from "@/components/StatsCard";
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { TrendChart } from "@/components/charts/TrendChart";
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils";
import { buildQuery, formatNumber, formatTokens } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n";
interface DetailData {
@@ -27,14 +28,14 @@ export default function DetailPage() {
const id = segments[1] || "";
const decodedId = decodeURIComponent(id);
const [range, setRange] = useState<TimeRange>("30d");
const { getEffectiveRange } = useTimeRange();
const [data, setData] = useState<DetailData | null>(null);
const [trends, setTrends] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
const { start, end } = getTimeRange(range);
const { start, end } = getEffectiveRange();
const tp = { start, end };
const [detail, tr] = await Promise.all([
fetch(buildQuery(`/api/detail/${type}/${encodeURIComponent(decodedId)}`, tp)).then(r => r.json()),
@@ -45,7 +46,7 @@ export default function DetailPage() {
})).then(r => r.json()),
]);
setData(detail); setTrends(tr); setLoading(false);
}, [range, type, decodedId]);
}, [type, decodedId, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -66,7 +67,7 @@ export default function DetailPage() {
<h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>{title}</h1>
</div>
</motion.div>
<TimeRangeSelector value={range} onChange={setRange} />
<TimeRangeSelector />
</div>
{loading ? (

View File

@@ -4,7 +4,8 @@ import { useEffect, useState, useCallback } from "react";
import { motion } from "motion/react";
import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react";
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens, formatDate } from "@/lib/utils";
import { buildQuery, formatNumber, formatTokens, formatDate } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n";
interface LogEntry {
@@ -16,7 +17,7 @@ interface LogEntry {
export default function LogsPage() {
const { t } = useI18n();
const [range, setRange] = useState<TimeRange>("7d");
const { getEffectiveRange } = useTimeRange();
const [page, setPage] = useState(1);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [total, setTotal] = useState(0);
@@ -26,14 +27,14 @@ export default function LogsPage() {
const fetchData = useCallback(async () => {
setLoading(true);
const { start, end } = getTimeRange(range);
const { start, end } = getEffectiveRange();
const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters }));
const data = await res.json();
setLogs(data.logs); setTotal(data.total); setLoading(false);
}, [range, page, filters]);
}, [page, filters, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => { setPage(1); }, [range, filters]);
useEffect(() => { setPage(1); }, [getEffectiveRange, filters]);
const totalPages = Math.ceil(total / pageSize);
const headers = [t("th.time"), t("th.user"), t("th.realModel"), t("th.channel"), t("th.input"), t("th.output"), t("th.totalToken"), t("th.latency"), ""];
@@ -45,7 +46,7 @@ export default function LogsPage() {
<ScrollText className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
<h1 className="text-2xl font-bold gradient-text">{t("logs.title")}</h1>
</motion.div>
<TimeRangeSelector value={range} onChange={setRange} />
<TimeRangeSelector />
</div>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">

View File

@@ -7,12 +7,13 @@ import { StatsCard } from "@/components/StatsCard";
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { TrendChart } from "@/components/charts/TrendChart";
import { RankingBar } from "@/components/charts/RankingBar";
import { type TimeRange, getTimeRange, buildQuery } from "@/lib/utils";
import { buildQuery } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n";
export default function DashboardPage() {
const { t } = useI18n();
const [range, setRange] = useState<TimeRange>("30d");
const { getEffectiveRange } = useTimeRange();
const [granularity, setGranularity] = useState<"day" | "week" | "month">("day");
const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens");
const [overview, setOverview] = useState<any>(null);
@@ -23,7 +24,7 @@ export default function DashboardPage() {
const fetchData = useCallback(async () => {
setLoading(true);
const { start, end } = getTimeRange(range);
const { start, end } = getEffectiveRange();
const tp = { start, end };
const [ov, tr, ur, mr] = await Promise.all([
fetch(buildQuery("/api/overview", tp)).then(r => r.json()),
@@ -32,7 +33,7 @@ export default function DashboardPage() {
fetch(buildQuery("/api/rankings", { ...tp, type: "model", limit: 10 })).then(r => r.json()),
]);
setOverview(ov); setTrends(tr); setUserRank(ur); setModelRank(mr); setLoading(false);
}, [range, granularity]);
}, [granularity, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -48,7 +49,7 @@ export default function DashboardPage() {
<motion.h1 initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="text-2xl font-bold gradient-text">
{t("dash.title")}
</motion.h1>
<TimeRangeSelector value={range} onChange={setRange} />
<TimeRangeSelector />
</div>
{overview && (

View File

@@ -5,7 +5,8 @@ import Link from "next/link";
import { motion } from "motion/react";
import { Trophy, Users, Cpu, Radio, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils";
import { buildQuery, formatNumber, formatTokens } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n";
type Tab = "user" | "model" | "channel";
@@ -18,7 +19,7 @@ interface RankItem {
export default function RankingsPage() {
const { t } = useI18n();
const [range, setRange] = useState<TimeRange>("30d");
const { getEffectiveRange } = useTimeRange();
const [tab, setTab] = useState<Tab>("user");
const [data, setData] = useState<RankItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -33,11 +34,11 @@ export default function RankingsPage() {
const fetchData = useCallback(async () => {
setLoading(true);
const { start, end } = getTimeRange(range);
const { start, end } = getEffectiveRange();
const res = await fetch(buildQuery("/api/rankings", { start, end, type: tab, limit: 100 }));
setData(await res.json());
setLoading(false);
}, [range, tab]);
}, [tab, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -80,7 +81,7 @@ export default function RankingsPage() {
<Trophy className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
<h1 className="text-2xl font-bold gradient-text">{t("rank.title")}</h1>
</motion.div>
<TimeRangeSelector value={range} onChange={setRange} />
<TimeRangeSelector />
</div>
<div className="flex gap-1 rounded-lg p-1 w-fit" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>