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:
7
app/api/date-range/route.ts
Normal file
7
app/api/date-range/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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)]"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
17
lib/utils.ts
17
lib/utils.ts
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user