From 7510918610e287d9413f53587914ebe758191c30 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 8 Jun 2026 14:29:31 +0800 Subject: [PATCH] fix(webui): align token usage heatmap --- .../src/components/settings/SettingsView.tsx | 2 +- .../components/settings/TokenUsageHeatmap.tsx | 71 ++++++++++++++++--- webui/src/tests/settings-view.test.tsx | 41 +++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 5b3d19646..fd726ea89 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1666,7 +1666,7 @@ function OverviewSettings({ return (
- +
diff --git a/webui/src/components/settings/TokenUsageHeatmap.tsx b/webui/src/components/settings/TokenUsageHeatmap.tsx index f08d99820..fc3d94728 100644 --- a/webui/src/components/settings/TokenUsageHeatmap.tsx +++ b/webui/src/components/settings/TokenUsageHeatmap.tsx @@ -24,15 +24,16 @@ type TokenUsageMonthLabel = { label: string; column: number; }; +type CalendarDayParts = { + year: string; + month: string; + day: string; +}; const TOKEN_HEATMAP_CELLS = 371; const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7); const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const; -function startOfUtcDay(date: Date): Date { - return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); -} - function addUtcDays(date: Date, days: number): Date { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); @@ -43,12 +44,56 @@ function isoDay(date: Date): string { return date.toISOString().slice(0, 10); } +function utcDateFromIsoDay(day: string): Date { + const [year, month, date] = day.split("-").map(Number); + return new Date(Date.UTC(year, month - 1, date)); +} + +function utcDayParts(date: Date): CalendarDayParts { + return { + year: String(date.getUTCFullYear()).padStart(4, "0"), + month: String(date.getUTCMonth() + 1).padStart(2, "0"), + day: String(date.getUTCDate()).padStart(2, "0"), + }; +} + +function dayPartsForTimeZone(date: Date, timeZone: string | undefined): CalendarDayParts { + if (!timeZone) return utcDayParts(date); + try { + const parts = new Intl.DateTimeFormat("en", { + calendar: "gregory", + numberingSystem: "latn", + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + if (values.year && values.month && values.day) { + return { + year: values.year.padStart(4, "0"), + month: values.month.padStart(2, "0"), + day: values.day.padStart(2, "0"), + }; + } + } catch { + // Fall through to UTC when the browser cannot resolve the configured timezone. + } + return utcDayParts(date); +} + +function todayIsoDay(timeZone: string | undefined): string { + const parts = dayPartsForTimeZone(new Date(), timeZone); + return `${parts.year}-${parts.month}-${parts.day}`; +} + function buildTokenUsageCalendar( days: TokenUsageDay[] | undefined, monthFormatter: Intl.DateTimeFormat, + timeZone: string | undefined, ): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } { const byDate = new Map((days ?? []).map((day) => [day.date, day])); - const today = startOfUtcDay(new Date()); + const today = utcDateFromIsoDay(todayIsoDay(timeZone)); const end = addUtcDays(today, 6 - today.getUTCDay()); const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1)); const seenMonths = new Set(); @@ -131,7 +176,13 @@ function tokenUsageCellClass(level: number, future: boolean): string { return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]"; } -export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) { +export function TokenUsageHeatmap({ + usage, + timeZone, +}: { + usage?: TokenUsagePayload; + timeZone?: string; +}) { const { t, i18n } = useTranslation(); const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); @@ -140,8 +191,8 @@ export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) { [i18n.language], ); const { cells, monthLabels } = useMemo( - () => buildTokenUsageCalendar(usage?.days, monthFormatter), - [monthFormatter, usage?.days], + () => buildTokenUsageCalendar(usage?.days, monthFormatter, timeZone), + [monthFormatter, timeZone, usage?.days], ); const maxTokens = Math.max(0, ...cells.map((cell) => cell.total)); @@ -154,14 +205,14 @@ export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) {
{monthLabels.map((month) => ( {month.label} diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 970426515..8d2714756 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -119,6 +119,7 @@ const installedAnyGen = { function renderSettingsView( options: { initialSection?: "overview" | "apps" | "advanced" | "models"; + initialSettings?: SettingsPayload; onSettingsChange?: (payload: SettingsPayload) => void; onNativeEngineRestart?: () => Promise; } = {}, @@ -128,6 +129,7 @@ function renderSettingsView( {}} onBackToChat={() => {}} onModelNameChange={() => {}} @@ -140,6 +142,7 @@ function renderSettingsView( describe("SettingsView Apps catalog", () => { afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -270,6 +273,44 @@ describe("SettingsView Apps catalog", () => { expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument(); }); + it("aligns token activity days with the configured timezone", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-02T18:00:00Z")); + const payload: SettingsPayload = { + ...settingsPayload(), + agent: { + ...settingsPayload().agent, + timezone: "Asia/Shanghai", + }, + usage: { + days: [ + { + date: "2026-06-03", + prompt_tokens: 1200, + completion_tokens: 300, + cached_tokens: 500, + total_tokens: 1500, + requests: 2, + }, + ], + total_tokens: 1500, + total_tokens_30d: 1500, + total_tokens_365d: 1500, + peak_day_tokens: 1500, + current_streak_days: 1, + longest_streak_days: 1, + active_days_30d: 1, + requests_30d: 2, + updated_at: "2026-06-03T00:00:00Z", + }, + }; + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + + renderSettingsView({ initialSection: "overview", initialSettings: payload }); + + expect(screen.getByLabelText("2026-06-03: 1.5K tokens, 2 requests")).toBeInTheDocument(); + }); + it("shows context window options in model settings", async () => { vi.stubGlobal( "fetch",