From 63fb051e6dce77107e8967526550b59195706c0d Mon Sep 17 00:00:00 2001 From: Evans Mike Date: Fri, 13 Feb 2026 10:28:58 +0000 Subject: [PATCH 1/3] fix: make timezone configurable instead of hardcoding Asia/Shanghai Add a TIMEZONE environment variable (defaults to Asia/Shanghai for backward compatibility) that controls how dates are grouped in PostgreSQL queries and displayed across the dashboard. - lib/config.ts: add normalizeTimezone() + config.timezone field - lib/queries/overview.ts: pass timezone into date_trunc / AT TIME ZONE - app/api/overview/route.ts: forward config.timezone to getOverview() - app/page/records/explore/logs: remove hardcoded timeZone option so Intl formatters use the browser's local timezone automatically - .env.example: document the new TIMEZONE variable --- .env.example | 7 ++++++- app/api/overview/route.ts | 5 +++-- app/explore/page.tsx | 1 - app/logs/page.tsx | 3 +-- app/page.tsx | 1 - app/records/page.tsx | 3 +-- lib/config.ts | 17 ++++++++++++++++- lib/queries/overview.ts | 9 +++++---- 8 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 114c9b4..d470202 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,9 @@ DATABASE_URL= # Security PASSWORD= -CRON_SECRET= \ No newline at end of file +CRON_SECRET= + +# Display timezone for date grouping in charts (IANA timezone name) +# Examples: UTC, Europe/London, America/New_York, Asia/Tokyo +# Defaults to Asia/Shanghai if not set. Set to your local timezone for correct day/hour grouping. +TIMEZONE= \ No newline at end of file diff --git a/app/api/overview/route.ts b/app/api/overview/route.ts index f3e0403..97e6b27 100644 --- a/app/api/overview/route.ts +++ b/app/api/overview/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { assertEnv } from "@/lib/config"; +import { assertEnv, config } from "@/lib/config"; import { getOverview } from "@/lib/queries/overview"; export const runtime = "nodejs"; @@ -85,7 +85,8 @@ export async function GET(request: Request) { page, pageSize, start, - end + end, + timezone: config.timezone }); const payload = { overview, empty, days: appliedDays, meta, filters }; diff --git a/app/explore/page.tsx b/app/explore/page.tsx index 6efc634..cedd164 100644 --- a/app/explore/page.tsx +++ b/app/explore/page.tsx @@ -175,7 +175,6 @@ function computeTimeTicks([min, max]: [number, number], maxTickCount = 8): numbe } const timeFormatter = new Intl.DateTimeFormat("zh-CN", { - timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", diff --git a/app/logs/page.tsx b/app/logs/page.tsx index 5379b4d..ce72296 100644 --- a/app/logs/page.tsx +++ b/app/logs/page.tsx @@ -26,8 +26,7 @@ function formatTimestamp(ts: number | undefined): string { hour: "2-digit", minute: "2-digit", second: "2-digit", - hour12: false, - timeZone: "Asia/Shanghai" + hour12: false }); } diff --git a/app/page.tsx b/app/page.tsx index 0d561f5..0d5dbb4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -33,7 +33,6 @@ type PriceForm = { }; const hourFormatter = new Intl.DateTimeFormat("en-CA", { - timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", diff --git a/app/records/page.tsx b/app/records/page.tsx index bd91fe5..47c9947 100644 --- a/app/records/page.tsx +++ b/app/records/page.tsx @@ -60,8 +60,7 @@ function formatTimestamp(ts: string) { hour: "2-digit", minute: "2-digit", second: "2-digit", - hour12: false, - timeZone: "Asia/Shanghai" + hour12: false }); } diff --git a/lib/config.ts b/lib/config.ts index a847ff5..56fe688 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -10,6 +10,20 @@ const baseUrl = normalizeBaseUrl(process.env.CLIPROXY_API_BASE_URL); const password = process.env.PASSWORD || process.env.CLIPROXY_SECRET_KEY || ""; const cronSecret = process.env.CRON_SECRET || ""; +function normalizeTimezone(raw: string | undefined): string { + const value = (raw || "").trim(); + if (!value) return "Asia/Shanghai"; + try { + Intl.DateTimeFormat(undefined, { timeZone: value }); + return value; + } catch { + console.warn(`TIMEZONE env var "${value}" is not a valid IANA timezone. Falling back to Asia/Shanghai.`); + return "UTC"; + } +} + +const timezone = normalizeTimezone(process.env.TIMEZONE); + export const config = { cliproxy: { baseUrl, @@ -17,7 +31,8 @@ export const config = { }, postgresUrl: process.env.DATABASE_URL || "", password, - cronSecret + cronSecret, + timezone }; export function assertEnv() { diff --git a/lib/queries/overview.ts b/lib/queries/overview.ts index ae85240..fac0925 100644 --- a/lib/queries/overview.ts +++ b/lib/queries/overview.ts @@ -84,7 +84,7 @@ function normalizePageSize(value?: number | null) { export async function getOverview( daysInput?: number, - opts?: { model?: string | null; route?: string | null; page?: number | null; pageSize?: number | null; start?: string | Date | null; end?: string | Date | null } + opts?: { model?: string | null; route?: string | null; page?: number | null; pageSize?: number | null; start?: string | Date | null; end?: string | Date | null; timezone?: string | null } ): Promise<{ overview: UsageOverview; empty: boolean; days: number; meta: OverviewMeta; filters: { models: string[]; routes: string[] } }> { const startDate = parseDateInput(opts?.start); const endDate = parseDateInput(opts?.end); @@ -106,8 +106,9 @@ export async function getOverview( if (opts?.route) filterWhereParts.push(eq(usageRecords.route, opts.route)); const filterWhere = filterWhereParts.length ? and(...filterWhereParts) : undefined; - const dayExpr = sql`date_trunc('day', ${usageRecords.occurredAt} at time zone 'Asia/Shanghai')`; - const hourExpr = sql`date_trunc('hour', ${usageRecords.occurredAt} at time zone 'Asia/Shanghai')`; + const tz = opts?.timezone || "Asia/Shanghai"; + const dayExpr = sql`date_trunc('day', ${usageRecords.occurredAt} at time zone ${tz})`; + const hourExpr = sql`date_trunc('hour', ${usageRecords.occurredAt} at time zone ${tz})`; const totalsPromise: Promise = db .select({ @@ -177,7 +178,7 @@ export async function getOverview( const byHourPromise: Promise = db .select({ label: sql`to_char(${hourExpr}, 'MM-DD HH24')`, - hourStart: sql`(${hourExpr}) at time zone 'Asia/Shanghai'`, + hourStart: sql`(${hourExpr}) at time zone ${tz}`, requests: sql`count(*)`, tokens: sql`sum(${usageRecords.totalTokens})`, inputTokens: sql`sum(${usageRecords.inputTokens})`, From c47a2e5d61253749a7658c46922f5334a14abcf7 Mon Sep 17 00:00:00 2001 From: Evans Mike Date: Fri, 13 Feb 2026 10:35:16 +0000 Subject: [PATCH 2/3] fix: embed timezone as SQL literal to avoid GROUP BY mismatch When the timezone is passed as a query parameter (e.g. $1, $3, $4), PostgreSQL treats each occurrence as a potentially distinct value and cannot match the SELECT expression to the GROUP BY clause, resulting in error 42803 (column must appear in GROUP BY or aggregate function). Switch to sql.raw() so the timezone is inlined as a literal string (e.g. AT TIME ZONE 'Europe/London'). The value is safe to embed because it has already been validated as a valid IANA timezone by normalizeTimezone() in lib/config.ts. --- lib/queries/overview.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/queries/overview.ts b/lib/queries/overview.ts index fac0925..679e714 100644 --- a/lib/queries/overview.ts +++ b/lib/queries/overview.ts @@ -107,8 +107,13 @@ export async function getOverview( const filterWhere = filterWhereParts.length ? and(...filterWhereParts) : undefined; const tz = opts?.timezone || "Asia/Shanghai"; - const dayExpr = sql`date_trunc('day', ${usageRecords.occurredAt} at time zone ${tz})`; - const hourExpr = sql`date_trunc('hour', ${usageRecords.occurredAt} at time zone ${tz})`; + // Use sql.raw() so the timezone is embedded as a SQL literal rather than a query + // parameter. PostgreSQL requires the GROUP BY expression to be textually identical + // to the SELECT expression; different parameter indices ($1 vs $3) would cause a + // "must appear in GROUP BY" error even when the values are equal. + const tzLiteral = sql.raw(`'${tz}'`); + const dayExpr = sql`date_trunc('day', ${usageRecords.occurredAt} at time zone ${tzLiteral})`; + const hourExpr = sql`date_trunc('hour', ${usageRecords.occurredAt} at time zone ${tzLiteral})`; const totalsPromise: Promise = db .select({ @@ -178,7 +183,7 @@ export async function getOverview( const byHourPromise: Promise = db .select({ label: sql`to_char(${hourExpr}, 'MM-DD HH24')`, - hourStart: sql`(${hourExpr}) at time zone ${tz}`, + hourStart: sql`(${hourExpr}) at time zone ${tzLiteral}`, requests: sql`count(*)`, tokens: sql`sum(${usageRecords.totalTokens})`, inputTokens: sql`sum(${usageRecords.inputTokens})`, From 7cf200e748e81c3201c13fbc5b5e37c246bba941 Mon Sep 17 00:00:00 2001 From: Evans Mike Date: Fri, 13 Feb 2026 10:58:18 +0000 Subject: [PATCH 3/3] fix: address review feedback on timezone inconsistencies - config.ts: fix catch branch returning 'UTC' while warning says 'Asia/Shanghai'; both now consistently fall back to Asia/Shanghai - getOverview() now returns the timezone it used for SQL bucketing, and the overview API includes it in the response payload - page.tsx: buildHourlySeries accepts an optional timezone parameter and uses it to create the gap-fill label formatter, so gap labels match the server-side bucket labels instead of using the browser's local timezone --- app/api/overview/route.ts | 5 +++-- app/page.tsx | 22 +++++++++++++++------- lib/config.ts | 2 +- lib/queries/overview.ts | 5 +++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/api/overview/route.ts b/app/api/overview/route.ts index 97e6b27..f3839f5 100644 --- a/app/api/overview/route.ts +++ b/app/api/overview/route.ts @@ -10,6 +10,7 @@ type CachedOverview = { overview: Awaited>["overview"] | null; empty: boolean; days: number; + timezone: string; meta?: Awaited>["meta"]; filters?: Awaited>["filters"]; }; @@ -79,7 +80,7 @@ export async function GET(request: Request) { } } - const { overview, empty, days: appliedDays, meta, filters } = await getOverview(days, { + const { overview, empty, days: appliedDays, meta, filters, timezone } = await getOverview(days, { model: model || undefined, route: route || undefined, page, @@ -89,7 +90,7 @@ export async function GET(request: Request) { timezone: config.timezone }); - const payload = { overview, empty, days: appliedDays, meta, filters }; + const payload = { overview, empty, days: appliedDays, meta, filters, timezone }; setCached(cacheKey, payload); return NextResponse.json(payload, { status: 200 }); } catch (error) { diff --git a/app/page.tsx b/app/page.tsx index 0d5dbb4..45fae8a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -23,7 +23,7 @@ const PIE_COLORS = [ ]; type OverviewMeta = { page: number; pageSize: number; totalModels: number; totalPages: number }; -type OverviewAPIResponse = { overview: UsageOverview | null; empty: boolean; days: number; meta?: OverviewMeta; filters?: { models: string[]; routes: string[] } }; +type OverviewAPIResponse = { overview: UsageOverview | null; empty: boolean; days: number; timezone?: string; meta?: OverviewMeta; filters?: { models: string[]; routes: string[] } }; type PriceForm = { model: string; @@ -65,15 +65,21 @@ const numericTooltipFormatter: TooltipProps["formatter"] = (valu return [formatNumberWithCommas(numericValue), name]; }; -function formatHourKeyFromTs(ts: number) { - const parts = hourFormatter.formatToParts(new Date(ts)); +function formatHourKeyFromTs(ts: number, formatter: Intl.DateTimeFormat) { + const parts = formatter.formatToParts(new Date(ts)); const month = parts.find((p) => p.type === "month")?.value ?? "00"; const day = parts.find((p) => p.type === "day")?.value ?? "00"; const hour = parts.find((p) => p.type === "hour")?.value ?? "00"; return `${month}-${day} ${hour}`; } -function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number) { +function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number, timezone?: string) { + // Use the server's bucketing timezone for gap-fill labels so they match the + // labels returned for real data points. Falls back to the module-level formatter + // (browser timezone) when no timezone is provided. + const gapFormatter = timezone + ? new Intl.DateTimeFormat("en-CA", { timeZone: timezone, month: "2-digit", day: "2-digit", hour: "2-digit", hour12: false }) + : hourFormatter; if (!series.length) return [] as UsageSeriesPoint[]; const withTs = series @@ -97,7 +103,7 @@ function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number) { filled.push(rest); } else { filled.push({ - label: formatHourKeyFromTs(ts), + label: formatHourKeyFromTs(ts, gapFormatter), timestamp: new Date(ts).toISOString(), requests: 0, tokens: 0, @@ -166,6 +172,7 @@ export default function DashboardPage() { } }, []); const [overview, setOverview] = useState(null); + const [bucketTimezone, setBucketTimezone] = useState(undefined); const [overviewError, setOverviewError] = useState(null); const [overviewEmpty, setOverviewEmpty] = useState(false); const [loadingOverview, setLoadingOverview] = useState(true); @@ -729,6 +736,7 @@ export default function DashboardPage() { const data: OverviewAPIResponse = await res.json(); if (!active) return; setOverview(data.overview ?? null); + setBucketTimezone(data.timezone); setOverviewEmpty(Boolean(data.empty)); setOverviewError(null); setPage(data.meta?.page ?? 1); @@ -759,8 +767,8 @@ export default function DashboardPage() { if (!overviewData?.byHour) return [] as UsageSeriesPoint[]; if (hourRange === "all") return overviewData.byHour; const hours = hourRange === "24h" ? 24 : 72; - return buildHourlySeries(overviewData.byHour, hours); - }, [hourRange, overviewData?.byHour]); + return buildHourlySeries(overviewData.byHour, hours, bucketTimezone); + }, [hourRange, overviewData?.byHour, bucketTimezone]); const hourlyLineStyle = useMemo( () => buildHourlyLineStyle(hourlySeries.length, 3), diff --git a/lib/config.ts b/lib/config.ts index 56fe688..df245e4 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -18,7 +18,7 @@ function normalizeTimezone(raw: string | undefined): string { return value; } catch { console.warn(`TIMEZONE env var "${value}" is not a valid IANA timezone. Falling back to Asia/Shanghai.`); - return "UTC"; + return "Asia/Shanghai"; } } diff --git a/lib/queries/overview.ts b/lib/queries/overview.ts index 679e714..bc5406e 100644 --- a/lib/queries/overview.ts +++ b/lib/queries/overview.ts @@ -85,7 +85,7 @@ function normalizePageSize(value?: number | null) { export async function getOverview( daysInput?: number, opts?: { model?: string | null; route?: string | null; page?: number | null; pageSize?: number | null; start?: string | Date | null; end?: string | Date | null; timezone?: string | null } -): Promise<{ overview: UsageOverview; empty: boolean; days: number; meta: OverviewMeta; filters: { models: string[]; routes: string[] } }> { +): Promise<{ overview: UsageOverview; empty: boolean; days: number; meta: OverviewMeta; filters: { models: string[]; routes: string[] }; timezone: string }> { const startDate = parseDateInput(opts?.start); const endDate = parseDateInput(opts?.end); const hasCustomRange = startDate && endDate && endDate >= startDate; @@ -338,6 +338,7 @@ export async function getOverview( empty: totalRequests === 0, days, meta: { page, pageSize, totalModels, totalPages }, - filters + filters, + timezone: tz }; }