fix(webui): align token usage heatmap

This commit is contained in:
chengyongru 2026-06-08 14:29:31 +08:00 committed by Xubin Ren
parent 631fdb4a46
commit 7510918610
3 changed files with 103 additions and 11 deletions

View File

@ -1666,7 +1666,7 @@ function OverviewSettings({
return (
<div className="space-y-7">
<section>
<TokenUsageHeatmap usage={settings.usage} />
<TokenUsageHeatmap usage={settings.usage} timeZone={settings.agent.timezone} />
</section>
<section>

View File

@ -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<string>();
@ -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<string, unknown>) =>
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 }) {
</span>
</div>
<div
className="mb-2 grid h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
className="mb-2 grid min-h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-hidden
>
{monthLabels.map((month) => (
<span
key={`${month.label}-${month.column}`}
className="truncate"
className="overflow-visible whitespace-nowrap"
style={{ gridColumnStart: month.column, gridColumnEnd: "span 4" }}
>
{month.label}

View File

@ -119,6 +119,7 @@ const installedAnyGen = {
function renderSettingsView(
options: {
initialSection?: "overview" | "apps" | "advanced" | "models";
initialSettings?: SettingsPayload;
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
@ -128,6 +129,7 @@ function renderSettingsView(
<SettingsView
theme="light"
initialSection={options.initialSection ?? "apps"}
initialSettings={options.initialSettings}
onToggleTheme={() => {}}
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<Response>(() => {})));
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",