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",