Skip to content

Commit 3c9acfa

Browse files
authored
Merge pull request #34 from etnperlong/fix/timezone
fix: make display timezone configurable via TIMEZONE env var
2 parents e521cfc + 7cf200e commit 3c9acfa

8 files changed

Lines changed: 58 additions & 25 deletions

File tree

.env.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@ DATABASE_URL=
77

88
# Security
99
PASSWORD=
10-
CRON_SECRET=<any-long-secret-string>
10+
CRON_SECRET=<any-long-secret-string>
11+
12+
# Display timezone for date grouping in charts (IANA timezone name)
13+
# Examples: UTC, Europe/London, America/New_York, Asia/Tokyo
14+
# Defaults to Asia/Shanghai if not set. Set to your local timezone for correct day/hour grouping.
15+
TIMEZONE=

app/api/overview/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from "next/server";
2-
import { assertEnv } from "@/lib/config";
2+
import { assertEnv, config } from "@/lib/config";
33
import { getOverview } from "@/lib/queries/overview";
44

55
export const runtime = "nodejs";
@@ -10,6 +10,7 @@ type CachedOverview = {
1010
overview: Awaited<ReturnType<typeof getOverview>>["overview"] | null;
1111
empty: boolean;
1212
days: number;
13+
timezone: string;
1314
meta?: Awaited<ReturnType<typeof getOverview>>["meta"];
1415
filters?: Awaited<ReturnType<typeof getOverview>>["filters"];
1516
};
@@ -79,16 +80,17 @@ export async function GET(request: Request) {
7980
}
8081
}
8182

82-
const { overview, empty, days: appliedDays, meta, filters } = await getOverview(days, {
83+
const { overview, empty, days: appliedDays, meta, filters, timezone } = await getOverview(days, {
8384
model: model || undefined,
8485
route: route || undefined,
8586
page,
8687
pageSize,
8788
start,
88-
end
89+
end,
90+
timezone: config.timezone
8991
});
9092

91-
const payload = { overview, empty, days: appliedDays, meta, filters };
93+
const payload = { overview, empty, days: appliedDays, meta, filters, timezone };
9294
setCached(cacheKey, payload);
9395
return NextResponse.json(payload, { status: 200 });
9496
} catch (error) {

app/explore/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@ function computeTimeTicks([min, max]: [number, number], maxTickCount = 8): numbe
175175
}
176176

177177
const timeFormatter = new Intl.DateTimeFormat("zh-CN", {
178-
timeZone: "Asia/Shanghai",
179178
month: "2-digit",
180179
day: "2-digit",
181180
hour: "2-digit",

app/logs/page.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ function formatTimestamp(ts: number | undefined): string {
2626
hour: "2-digit",
2727
minute: "2-digit",
2828
second: "2-digit",
29-
hour12: false,
30-
timeZone: "Asia/Shanghai"
29+
hour12: false
3130
});
3231
}
3332

app/page.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const PIE_COLORS = [
2323
];
2424

2525
type OverviewMeta = { page: number; pageSize: number; totalModels: number; totalPages: number };
26-
type OverviewAPIResponse = { overview: UsageOverview | null; empty: boolean; days: number; meta?: OverviewMeta; filters?: { models: string[]; routes: string[] } };
26+
type OverviewAPIResponse = { overview: UsageOverview | null; empty: boolean; days: number; timezone?: string; meta?: OverviewMeta; filters?: { models: string[]; routes: string[] } };
2727

2828
type PriceForm = {
2929
model: string;
@@ -33,7 +33,6 @@ type PriceForm = {
3333
};
3434

3535
const hourFormatter = new Intl.DateTimeFormat("en-CA", {
36-
timeZone: "Asia/Shanghai",
3736
month: "2-digit",
3837
day: "2-digit",
3938
hour: "2-digit",
@@ -66,15 +65,21 @@ const numericTooltipFormatter: TooltipProps<number, string>["formatter"] = (valu
6665
return [formatNumberWithCommas(numericValue), name];
6766
};
6867

69-
function formatHourKeyFromTs(ts: number) {
70-
const parts = hourFormatter.formatToParts(new Date(ts));
68+
function formatHourKeyFromTs(ts: number, formatter: Intl.DateTimeFormat) {
69+
const parts = formatter.formatToParts(new Date(ts));
7170
const month = parts.find((p) => p.type === "month")?.value ?? "00";
7271
const day = parts.find((p) => p.type === "day")?.value ?? "00";
7372
const hour = parts.find((p) => p.type === "hour")?.value ?? "00";
7473
return `${month}-${day} ${hour}`;
7574
}
7675

77-
function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number) {
76+
function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number, timezone?: string) {
77+
// Use the server's bucketing timezone for gap-fill labels so they match the
78+
// labels returned for real data points. Falls back to the module-level formatter
79+
// (browser timezone) when no timezone is provided.
80+
const gapFormatter = timezone
81+
? new Intl.DateTimeFormat("en-CA", { timeZone: timezone, month: "2-digit", day: "2-digit", hour: "2-digit", hour12: false })
82+
: hourFormatter;
7883
if (!series.length) return [] as UsageSeriesPoint[];
7984

8085
const withTs = series
@@ -98,7 +103,7 @@ function buildHourlySeries(series: UsageSeriesPoint[], rangeHours?: number) {
98103
filled.push(rest);
99104
} else {
100105
filled.push({
101-
label: formatHourKeyFromTs(ts),
106+
label: formatHourKeyFromTs(ts, gapFormatter),
102107
timestamp: new Date(ts).toISOString(),
103108
requests: 0,
104109
tokens: 0,
@@ -167,6 +172,7 @@ export default function DashboardPage() {
167172
}
168173
}, []);
169174
const [overview, setOverview] = useState<UsageOverview | null>(null);
175+
const [bucketTimezone, setBucketTimezone] = useState<string | undefined>(undefined);
170176
const [overviewError, setOverviewError] = useState<string | null>(null);
171177
const [overviewEmpty, setOverviewEmpty] = useState(false);
172178
const [loadingOverview, setLoadingOverview] = useState(true);
@@ -730,6 +736,7 @@ export default function DashboardPage() {
730736
const data: OverviewAPIResponse = await res.json();
731737
if (!active) return;
732738
setOverview(data.overview ?? null);
739+
setBucketTimezone(data.timezone);
733740
setOverviewEmpty(Boolean(data.empty));
734741
setOverviewError(null);
735742
setPage(data.meta?.page ?? 1);
@@ -760,8 +767,8 @@ export default function DashboardPage() {
760767
if (!overviewData?.byHour) return [] as UsageSeriesPoint[];
761768
if (hourRange === "all") return overviewData.byHour;
762769
const hours = hourRange === "24h" ? 24 : 72;
763-
return buildHourlySeries(overviewData.byHour, hours);
764-
}, [hourRange, overviewData?.byHour]);
770+
return buildHourlySeries(overviewData.byHour, hours, bucketTimezone);
771+
}, [hourRange, overviewData?.byHour, bucketTimezone]);
765772

766773
const hourlyLineStyle = useMemo(
767774
() => buildHourlyLineStyle(hourlySeries.length, 3),

app/records/page.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ function formatTimestamp(ts: string) {
6060
hour: "2-digit",
6161
minute: "2-digit",
6262
second: "2-digit",
63-
hour12: false,
64-
timeZone: "Asia/Shanghai"
63+
hour12: false
6564
});
6665
}
6766

lib/config.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,29 @@ const baseUrl = normalizeBaseUrl(process.env.CLIPROXY_API_BASE_URL);
1010
const password = process.env.PASSWORD || process.env.CLIPROXY_SECRET_KEY || "";
1111
const cronSecret = process.env.CRON_SECRET || "";
1212

13+
function normalizeTimezone(raw: string | undefined): string {
14+
const value = (raw || "").trim();
15+
if (!value) return "Asia/Shanghai";
16+
try {
17+
Intl.DateTimeFormat(undefined, { timeZone: value });
18+
return value;
19+
} catch {
20+
console.warn(`TIMEZONE env var "${value}" is not a valid IANA timezone. Falling back to Asia/Shanghai.`);
21+
return "Asia/Shanghai";
22+
}
23+
}
24+
25+
const timezone = normalizeTimezone(process.env.TIMEZONE);
26+
1327
export const config = {
1428
cliproxy: {
1529
baseUrl,
1630
apiKey: process.env.CLIPROXY_SECRET_KEY || ""
1731
},
1832
postgresUrl: process.env.DATABASE_URL || "",
1933
password,
20-
cronSecret
34+
cronSecret,
35+
timezone
2136
};
2237

2338
export function assertEnv() {

lib/queries/overview.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ function normalizePageSize(value?: number | null) {
8484

8585
export async function getOverview(
8686
daysInput?: number,
87-
opts?: { model?: string | null; route?: string | null; page?: number | null; pageSize?: number | null; start?: string | Date | null; end?: string | Date | null }
88-
): Promise<{ overview: UsageOverview; empty: boolean; days: number; meta: OverviewMeta; filters: { models: string[]; routes: string[] } }> {
87+
opts?: { model?: string | null; route?: string | null; page?: number | null; pageSize?: number | null; start?: string | Date | null; end?: string | Date | null; timezone?: string | null }
88+
): Promise<{ overview: UsageOverview; empty: boolean; days: number; meta: OverviewMeta; filters: { models: string[]; routes: string[] }; timezone: string }> {
8989
const startDate = parseDateInput(opts?.start);
9090
const endDate = parseDateInput(opts?.end);
9191
const hasCustomRange = startDate && endDate && endDate >= startDate;
@@ -106,8 +106,14 @@ export async function getOverview(
106106
if (opts?.route) filterWhereParts.push(eq(usageRecords.route, opts.route));
107107
const filterWhere = filterWhereParts.length ? and(...filterWhereParts) : undefined;
108108

109-
const dayExpr = sql`date_trunc('day', ${usageRecords.occurredAt} at time zone 'Asia/Shanghai')`;
110-
const hourExpr = sql`date_trunc('hour', ${usageRecords.occurredAt} at time zone 'Asia/Shanghai')`;
109+
const tz = opts?.timezone || "Asia/Shanghai";
110+
// Use sql.raw() so the timezone is embedded as a SQL literal rather than a query
111+
// parameter. PostgreSQL requires the GROUP BY expression to be textually identical
112+
// to the SELECT expression; different parameter indices ($1 vs $3) would cause a
113+
// "must appear in GROUP BY" error even when the values are equal.
114+
const tzLiteral = sql.raw(`'${tz}'`);
115+
const dayExpr = sql`date_trunc('day', ${usageRecords.occurredAt} at time zone ${tzLiteral})`;
116+
const hourExpr = sql`date_trunc('hour', ${usageRecords.occurredAt} at time zone ${tzLiteral})`;
111117

112118
const totalsPromise: Promise<TotalsRow[]> = db
113119
.select({
@@ -177,7 +183,7 @@ export async function getOverview(
177183
const byHourPromise: Promise<HourAggRow[]> = db
178184
.select({
179185
label: sql<string>`to_char(${hourExpr}, 'MM-DD HH24')`,
180-
hourStart: sql<Date>`(${hourExpr}) at time zone 'Asia/Shanghai'`,
186+
hourStart: sql<Date>`(${hourExpr}) at time zone ${tzLiteral}`,
181187
requests: sql<number>`count(*)`,
182188
tokens: sql<number>`sum(${usageRecords.totalTokens})`,
183189
inputTokens: sql<number>`sum(${usageRecords.inputTokens})`,
@@ -332,6 +338,7 @@ export async function getOverview(
332338
empty: totalRequests === 0,
333339
days,
334340
meta: { page, pageSize, totalModels, totalPages },
335-
filters
341+
filters,
342+
timezone: tz
336343
};
337344
}

0 commit comments

Comments
 (0)