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:
2026-04-07 16:22:18 +08:00
parent 35b8fec96c
commit 13805a47be
5 changed files with 108 additions and 62 deletions

View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { getDateRange } from "@/lib/queries";
export async function GET() {
const data = await getDateRange();
return NextResponse.json(data);
}

View File

@@ -8,18 +8,14 @@ import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
export function TimeRangeSelector() { export function TimeRangeSelector() {
const { t, locale } = useI18n(); const { t } = useI18n();
const { range, setRange, customStart, customEnd, setCustomStart, setCustomEnd } = useTimeRange(); const { range, setRange, customStart, customEnd, setCustomStart, setCustomEnd } = useTimeRange();
const [showPopover, setShowPopover] = useState(false); const [showPopover, setShowPopover] = useState(false);
const [localStart, setLocalStart] = useState(customStart); const [localStart, setLocalStart] = useState("");
const [localEnd, setLocalEnd] = useState(customEnd); const [localEnd, setLocalEnd] = useState("");
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Sync local state when context changes
useEffect(() => { setLocalStart(customStart); }, [customStart]);
useEffect(() => { setLocalEnd(customEnd); }, [customEnd]);
// Close popover on click outside // Close popover on click outside
useEffect(() => { useEffect(() => {
if (!showPopover) return; if (!showPopover) return;
@@ -48,7 +44,9 @@ export function TimeRangeSelector() {
if (range === "custom" && showPopover) { if (range === "custom" && showPopover) {
setShowPopover(false); setShowPopover(false);
} else { } else {
setRange("custom"); // customStart/customEnd already reflects the current preset's dates
setLocalStart(customStart);
setLocalEnd(customEnd);
setShowPopover(true); setShowPopover(true);
} }
} }
@@ -56,6 +54,7 @@ export function TimeRangeSelector() {
function handleConfirm() { function handleConfirm() {
setCustomStart(localStart); setCustomStart(localStart);
setCustomEnd(localEnd); setCustomEnd(localEnd);
setRange("custom");
setShowPopover(false); setShowPopover(false);
} }
@@ -126,7 +125,6 @@ export function TimeRangeSelector() {
</label> </label>
<input <input
type="date" type="date"
lang={locale === "zh" ? "zh-CN" : "en-US"}
value={localStart} value={localStart}
onChange={(e) => setLocalStart(e.target.value)} onChange={(e) => setLocalStart(e.target.value)}
className="input-glass w-full rounded-md px-3 py-1.5 text-xs font-[family-name:var(--font-geist-mono)]" className="input-glass w-full rounded-md px-3 py-1.5 text-xs font-[family-name:var(--font-geist-mono)]"
@@ -138,7 +136,6 @@ export function TimeRangeSelector() {
</label> </label>
<input <input
type="date" type="date"
lang={locale === "zh" ? "zh-CN" : "en-US"}
value={localEnd} value={localEnd}
onChange={(e) => setLocalEnd(e.target.value)} onChange={(e) => setLocalEnd(e.target.value)}
className="input-glass w-full rounded-md px-3 py-1.5 text-xs font-[family-name:var(--font-geist-mono)]" className="input-glass w-full rounded-md px-3 py-1.5 text-xs font-[family-name:var(--font-geist-mono)]"

View File

@@ -6,6 +6,18 @@ const REAL_MODEL = `COALESCE(
THEN other::jsonb->>'upstream_model_name' END, THEN other::jsonb->>'upstream_model_name' END,
model_name)`; model_name)`;
// ── 数据时间边界 ────────────────────────────────────────────────
export async function getDateRange(): Promise<{ minDate: string; maxDate: string }> {
const rows = await query(
`SELECT
((MIN(to_timestamp(created_at)) AT TIME ZONE 'Asia/Shanghai')::date)::text as min_date,
((MAX(to_timestamp(created_at)) AT TIME ZONE 'Asia/Shanghai')::date)::text as max_date
FROM logs WHERE type = 2`
);
return { minDate: rows[0]?.min_date ?? "", maxDate: rows[0]?.max_date ?? "" };
}
// 时间条件构建 // 时间条件构建
function timeWhere( function timeWhere(
params: (string | number | boolean | null)[], params: (string | number | boolean | null)[],
@@ -19,7 +31,7 @@ function timeWhere(
} }
if (endTs) { if (endTs) {
params.push(endTs); params.push(endTs);
where += ` AND created_at < $${params.length}`; where += ` AND created_at <= $${params.length}`;
} }
return where; return where;
} }
@@ -91,8 +103,8 @@ export async function getTrends(
const truncExpr = const truncExpr =
granularity === "day" granularity === "day"
? `(to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date` ? `((to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`
: `date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date`; : `(date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`;
const rows = await query( const rows = await query(
`SELECT `SELECT
@@ -107,7 +119,7 @@ export async function getTrends(
); );
return rows.map((r) => ({ return rows.map((r) => ({
date: r.date instanceof Date ? r.date.toISOString().slice(0, 10) : String(r.date).slice(0, 10), date: String(r.date).slice(0, 10),
calls: Number(r.calls), calls: Number(r.calls),
prompt_tokens: Number(r.prompt_tokens), prompt_tokens: Number(r.prompt_tokens),
completion_tokens: Number(r.completion_tokens), completion_tokens: Number(r.completion_tokens),

View File

@@ -1,8 +1,8 @@
"use client"; "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 dayjs from "dayjs";
import { type TimeRange, getTimeRange } from "@/lib/utils"; import { type TimeRange } from "@/lib/utils";
interface TimeRangeContextType { interface TimeRangeContextType {
range: TimeRange; range: TimeRange;
@@ -16,7 +16,7 @@ interface TimeRangeContextType {
} }
const TimeRangeContext = createContext<TimeRangeContextType>({ const TimeRangeContext = createContext<TimeRangeContextType>({
range: "30d", range: "7d",
setRange: () => {}, setRange: () => {},
customStart: "", customStart: "",
customEnd: "", customEnd: "",
@@ -25,9 +25,8 @@ const TimeRangeContext = createContext<TimeRangeContextType>({
getEffectiveRange: () => ({}), getEffectiveRange: () => ({}),
}); });
// Use useSyncExternalStore to safely read localStorage without hydration mismatch
const STORAGE_KEY = "time-range"; const STORAGE_KEY = "time-range";
const DEFAULT_RANGE = "30d"; const DEFAULT_RANGE: TimeRange = "7d";
let listeners: Array<() => void> = []; let listeners: Array<() => void> = [];
function subscribe(cb: () => void) { function subscribe(cb: () => void) {
@@ -50,6 +49,30 @@ function persist(range: TimeRange, customStart: string, customEnd: string) {
emitChange(); 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 }) { export function TimeRangeProvider({ children }: { children: ReactNode }) {
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
@@ -58,46 +81,70 @@ export function TimeRangeProvider({ children }: { children: ReactNode }) {
try { return JSON.parse(raw); } catch { return null; } try { return JSON.parse(raw); } catch { return null; }
})(); })();
const init = initDates(saved);
const [range, setRangeState] = useState<TimeRange>(saved?.range ?? DEFAULT_RANGE); const [range, setRangeState] = useState<TimeRange>(saved?.range ?? DEFAULT_RANGE);
const [customStart, setCustomStartState] = useState( const [customStart, setCustomStartState] = useState(init.start);
saved?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD") const [customEnd, setCustomEndState] = useState(init.end);
);
const [customEnd, setCustomEndState] = useState( const rangeRef = useRef(range);
saved?.customEnd || dayjs().format("YYYY-MM-DD") 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) => { const setRange = useCallback((r: TimeRange) => {
setRangeState(r); setRangeState(r);
setCustomStartState((prev: string) => { if (r === "all") {
setCustomEndState((end: string) => { persist(r, prev, end); return end; }); // Query first, then backfill dates
return prev; 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) => { const setCustomStart = useCallback((s: string) => {
setCustomStartState(s); setCustomStartState(s);
setRangeState((r: TimeRange) => { persist(rangeRef.current, s, customEndRef.current);
setCustomEndState((end: string) => { persist(r, s, end); return end; });
return r;
});
}, []); }, []);
const setCustomEnd = useCallback((e: string) => { const setCustomEnd = useCallback((e: string) => {
setCustomEndState(e); setCustomEndState(e);
setRangeState((r: TimeRange) => { persist(rangeRef.current, customStartRef.current, e);
setCustomStartState((start: string) => { persist(r, start, e); return start; });
return r;
});
}, []); }, []);
// Always derive timestamps from customStart/customEnd — single source of truth
const getEffectiveRange = useCallback(() => { const getEffectiveRange = useCallback(() => {
if (range === "custom") { // "all" with dates not yet loaded → no filter
const result: { start?: number; end?: number } = {}; if (range === "all" && !customStart && !customEnd) return {};
if (customStart) result.start = dayjs(customStart).startOf("day").unix(); const result: { start?: number; end?: number } = {};
if (customEnd) result.end = dayjs(customEnd).endOf("day").unix(); if (customStart) result.start = dayjs(customStart).startOf("day").unix();
return result; if (customEnd) result.end = dayjs(customEnd).endOf("day").unix();
} return result;
return getTimeRange(range);
}, [range, customStart, customEnd]); }, [range, customStart, customEnd]);
return ( return (

View File

@@ -25,23 +25,6 @@ export function formatDate(iso: string): string {
// 预设时间范围 // 预设时间范围
export type TimeRange = "today" | "7d" | "30d" | "all" | "custom"; 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 };
case "7d":
return { start: now.subtract(7, "day").startOf("day").unix(), end };
case "30d":
return { start: now.subtract(30, "day").startOf("day").unix(), end };
case "all":
return {};
default:
return {};
}
}
export function buildQuery( export function buildQuery(
base: string, base: string,
params: Record<string, string | number | undefined> params: Record<string, string | number | undefined>