diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index d32fa523..3ba4db89 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -89,10 +89,11 @@ describe('convertAgentMessage', () => { totalTokens: 13_892, contextTokens: 13_879, contextWindow: 65_536 - }); + }, 'kimi-k2.5'); expect(converted).toEqual({ type: 'token_count', + model: 'kimi-k2.5', info: { total: { inputTokens: 8119, @@ -106,6 +107,19 @@ describe('convertAgentMessage', () => { } }); }); + + it('stamps unknown usage models explicitly', () => { + const converted = convertAgentMessage({ + type: 'usage', + inputTokens: 10, + outputTokens: 2 + }); + + expect(converted).toMatchObject({ + type: 'token_count', + model: null + }); + }); it('returns null instead of echoing an unrecognized message shape', () => { // Unreachable through the type system, but callers forward any non-null // result straight into the chat stream — so the runtime contract has to diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts index 034f7dad..acd85134 100644 --- a/cli/src/agent/messageConverter.ts +++ b/cli/src/agent/messageConverter.ts @@ -6,6 +6,7 @@ export type CodexMessage = | { type: 'reasoning'; message: string; id: string } | { type: 'token_count'; + model: string | null; info: { total: { inputTokens: number; @@ -36,7 +37,7 @@ export type CodexMessage = | { type: 'plan'; entries: PlanItem[] } | { type: 'error'; message: string }; -export function convertAgentMessage(message: AgentMessage): CodexMessage | null { +export function convertAgentMessage(message: AgentMessage, model?: string | null): CodexMessage | null { switch (message.type) { case 'text': return { type: 'message', message: message.text }; @@ -48,6 +49,7 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null case 'usage': return { type: 'token_count', + model: typeof model === 'string' && model.trim() ? model.trim() : null, info: { total: { inputTokens: message.inputTokens, diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index a4e10046..efd33c28 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -188,7 +188,8 @@ export async function runAgentSession(opts: { try { await backend.prompt(agentSessionId, promptContent, (message) => { - const converted = convertAgentMessage(message); + const model = backend.getSessionModelsMetadata?.(agentSessionId)?.currentModelId; + const converted = convertAgentMessage(message, model); if (converted) { session.sendAgentMessage(converted); } diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 10e286fa..97e78fc0 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -373,9 +373,9 @@ describe('codexLocalLauncher', () => { }); }); - it('tracks explicit and default reasoning effort from local turn context', async () => { + it('tracks local turn context and stamps its model on usage', async () => { const transcriptPath = await writeTranscriptMeta('codex-turn-context.jsonl', 'codex-thread-effort'); - const { session, getModelReasoningEffort, getModelReasoningEffortUpdates } = createSessionStub('default'); + const { session, agentMessages, getModelReasoningEffort, getModelReasoningEffortUpdates } = createSessionStub('default'); let releaseRunBarrier: (() => void) | undefined; harness.runBarrier = new Promise((resolve) => { releaseRunBarrier = resolve; @@ -393,13 +393,13 @@ describe('codexLocalLauncher', () => { type: 'turn_context', payload: { effort: 'max' } }), - JSON.stringify({ - type: 'event_msg', - payload: { type: 'token_count', info: {} } - }), JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.4' } + }), + JSON.stringify({ + type: 'event_msg', + payload: { type: 'token_count', info: {} } }) ].join('\n') + '\n'); await wait(700); @@ -409,6 +409,10 @@ describe('codexLocalLauncher', () => { expect(getModelReasoningEffortUpdates()).toEqual(['max', null]); expect(getModelReasoningEffort()).toBeNull(); + expect(agentMessages).toContainEqual(expect.objectContaining({ + type: 'token_count', + model: 'gpt-5.4' + })); }); it('renders nested Code Mode plans and commands without their covered exec wrapper', async () => { diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 6c5a21b8..43b00921 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -36,6 +36,16 @@ function extractTurnContextReasoningEffort(event: CodexSessionEvent): ReasoningE return effort.trim().toLowerCase(); } +function extractTurnContextModel(event: CodexSessionEvent): string | null | undefined { + if (event.type !== 'turn_context' || !event.payload || typeof event.payload !== 'object') { + return undefined; + } + const model = (event.payload as Record).model; + if (model === null) return null; + if (typeof model !== 'string' || !model.trim()) return undefined; + return model.trim(); +} + export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { const resumeSessionId = session.sessionId; let primarySessionId = resumeSessionId; @@ -47,6 +57,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch let transcriptLocator: CodexTranscriptLocator | null = null; let scannerTranscriptPath: string | null = null; let scannerReplayedExistingHistory = false; + let transcriptModel: string | null = null; const pendingPlansByTurnId = new Map(); const pendingExecWrappers = new Map(); const toolHookBridge = new CodexToolHookBridge(); @@ -203,6 +214,10 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch session.onSessionFound(sessionId); }, onEvent: (event, context) => { + const observedModel = extractTurnContextModel(event); + if (observedModel !== undefined) { + transcriptModel = observedModel; + } const observedReasoningEffort = extractTurnContextReasoningEffort(event); if (observedReasoningEffort !== undefined) { session.setModelReasoningEffort(observedReasoningEffort); @@ -245,15 +260,16 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch const scopedMessage = message.type !== 'token_count' ? message : context.replayedHistory - ? { ...message, hapiUsageScope: 'imported-history' } + ? { ...message, model: transcriptModel, hapiUsageScope: 'imported-history' } : primarySessionId ? { ...message, + model: transcriptModel, threadId: primarySessionId, thread_id: primarySessionId, hapiUsageScope: 'managed' } - : message; + : { ...message, model: transcriptModel }; session.sendAgentMessage(scopedMessage); } } diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index b6d52767..3cbc12f5 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -681,6 +681,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let scheduleReadyAfterTurn: (() => void) | null = null; let clearReadyAfterTurnTimer: (() => void) | null = null; let turnInFlight = false; + let usageModel: string | null = null; let allowAnonymousTerminalEvent = false; let invalidThreadId: string | null = null; let childAgentActivityInCurrentTurn = false; @@ -2874,6 +2875,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const threadId = eventThreadId ?? this.currentThreadId; session.sendAgentMessage({ ...addCodexEventScope(msg, 'parent', threadId), + model: asString(msg.model) ?? usageModel, id: randomUUID() }); } @@ -3784,6 +3786,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { ...message.mode, model: session.getModel() ?? message.mode.model }; + usageModel = typeof mode.model === 'string' && mode.model.trim() + ? mode.model.trim() + : null; const shouldSendCollaborationMode = supportsTurnCollaborationMode && Boolean(mode.collaborationMode); const clientUserMessageId = message.items diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index de30a4af..e0d35a9d 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -458,7 +458,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } private handleAgentMessage(message: AgentMessage): void { - const converted = convertAgentMessage(message); + const converted = convertAgentMessage(message, this.currentBackendModel); if (converted) { this.session.sendAgentMessage(converted); } diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index e0448c47..3d4437f1 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -195,7 +195,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { } else if (event.type === 'assistant' || event.type === 'tool_call' || event.type === 'result') { const agentMsg = convertCursorEventToAgentMessage(event); if (agentMsg) { - const codexMsg = convertAgentMessage(agentMsg); + const codexMsg = convertAgentMessage(agentMsg, session.model); if (codexMsg) { session.sendAgentMessage(codexMsg); } diff --git a/cli/src/grok/grokRemoteLauncher.ts b/cli/src/grok/grokRemoteLauncher.ts index 54acb67c..c0da98b9 100644 --- a/cli/src/grok/grokRemoteLauncher.ts +++ b/cli/src/grok/grokRemoteLauncher.ts @@ -384,7 +384,7 @@ class GrokRemoteLauncher extends RemoteLauncherBase { } private handleAgentMessage(message: AgentMessage): void { - const converted = convertAgentMessage(message) + const converted = convertAgentMessage(message, this.currentBackendModel) if (converted) this.session.sendAgentMessage(converted) switch (message.type) { diff --git a/cli/src/kimi/kimiLocalLauncher.ts b/cli/src/kimi/kimiLocalLauncher.ts index 49912ba5..bcd6d8f2 100644 --- a/cli/src/kimi/kimiLocalLauncher.ts +++ b/cli/src/kimi/kimiLocalLauncher.ts @@ -42,7 +42,7 @@ export async function kimiLocalLauncher( if (shuttingDown) { return; } - const converted = convertKimiWireEvent(event); + const converted = convertKimiWireEvent(event, session.getModel() ?? opts.model); if (!converted) { return; } diff --git a/cli/src/kimi/kimiRemoteLauncher.ts b/cli/src/kimi/kimiRemoteLauncher.ts index 1bcd8738..36e67931 100644 --- a/cli/src/kimi/kimiRemoteLauncher.ts +++ b/cli/src/kimi/kimiRemoteLauncher.ts @@ -222,7 +222,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase { } private handleAgentMessage(message: AgentMessage): void { - const converted = convertAgentMessage(message); + const converted = convertAgentMessage(message, this.currentBackendModel); if (converted) { this.session.sendAgentMessage(converted); } diff --git a/cli/src/kimi/utils/kimiWireScanner.test.ts b/cli/src/kimi/utils/kimiWireScanner.test.ts index 660255aa..45b8a9e8 100644 --- a/cli/src/kimi/utils/kimiWireScanner.test.ts +++ b/cli/src/kimi/utils/kimiWireScanner.test.ts @@ -77,9 +77,10 @@ describe('convertKimiWireEvent', () => { uuid: 's1', usage: { inputOther: 100, output: 20, inputCacheRead: 50, inputCacheCreation: 10 } } - })).toEqual({ + }, 'kimi-k2.5')).toEqual({ message: { type: 'token_count', + model: 'kimi-k2.5', info: { total: { inputTokens: 160, outputTokens: 20, cachedInputTokens: 50 } } } }); diff --git a/cli/src/kimi/utils/kimiWireScanner.ts b/cli/src/kimi/utils/kimiWireScanner.ts index 24e3a2d8..1a644ec6 100644 --- a/cli/src/kimi/utils/kimiWireScanner.ts +++ b/cli/src/kimi/utils/kimiWireScanner.ts @@ -65,7 +65,7 @@ function extractInputText(input: unknown): string | null { * Everything else (metadata, config.update, llm.request, usage.record, * step.begin, plan_mode.*, …) is ignored. */ -export function convertKimiWireEvent(event: KimiWireEvent): KimiWireConversion | null { +export function convertKimiWireEvent(event: KimiWireEvent, model?: string | null): KimiWireConversion | null { if (event.type === 'turn.prompt' || event.type === 'turn.steer') { const origin = asRecord(event.origin); if (asString(origin?.kind) !== 'user') { @@ -149,6 +149,7 @@ export function convertKimiWireEvent(event: KimiWireEvent): KimiWireConversion | return { message: { type: 'token_count', + model: typeof model === 'string' && model.trim() ? model.trim() : null, info: { total: { inputTokens: inputOther + cacheRead + cacheCreation, diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 3dea38e0..425ea7c7 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -767,7 +767,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { } private handleAgentMessage(message: AgentMessage): void { - const converted = convertAgentMessage(message); + const converted = convertAgentMessage(message, this.currentBackendModel); if (converted) { this.session.sendAgentMessage(converted); } diff --git a/cli/src/pi/loop.ts b/cli/src/pi/loop.ts index 0b1210b5..82be9348 100644 --- a/cli/src/pi/loop.ts +++ b/cli/src/pi/loop.ts @@ -326,7 +326,7 @@ async function publishPiTurnUsage( const usageMessage = convertPiTurnUsage(event, contextUsage); if (!usageMessage) return; - const converted = convertAgentMessage(usageMessage); + const converted = convertAgentMessage(usageMessage, session.currentModel); if (converted) session.sendAgentMessage(converted); } @@ -362,7 +362,7 @@ export function wireTransportEvents( const accumulated = assistantMessageAccumulator.handleEvent(event); if (accumulated.length > 0) { for (const msg of accumulated) { - const converted = convertAgentMessage(msg); + const converted = convertAgentMessage(msg, session.currentModel); if (converted) session.sendAgentMessage(converted); } } @@ -371,7 +371,7 @@ export function wireTransportEvents( if (event.type !== 'message_start' && event.type !== 'message_update' && event.type !== 'message_end') { const messages = convertPiEvent(event); for (const msg of messages) { - const converted = convertAgentMessage(msg); + const converted = convertAgentMessage(msg, session.currentModel); if (converted) session.sendAgentMessage(converted); } } diff --git a/hub/src/sync/usageService.test.ts b/hub/src/sync/usageService.test.ts index 2b8ee125..a0d673fd 100644 --- a/hub/src/sync/usageService.test.ts +++ b/hub/src/sync/usageService.test.ts @@ -2,8 +2,18 @@ import { describe, expect, it } from 'bun:test' import { Store } from '../store' import { getUsageSummary } from './usageService' -function addAgentMessage(store: Store, sessionId: string, content: unknown): void { - store.messages.addMessage(sessionId, { role: 'agent', content }) +function addAgentMessage(store: Store, sessionId: string, content: unknown, createdAt?: number): void { + if (createdAt === undefined) { + store.messages.addMessage(sessionId, { role: 'agent', content }) + return + } + store.messages.copyMessageToSession(sessionId, { + content: { role: 'agent', content }, + createdAt, + localId: null, + invokedAt: createdAt, + scheduledAt: null + }) } describe('usage service', () => { @@ -172,6 +182,85 @@ describe('usage service', () => { expect(result.totals.requests).toBe(1) expect(result.totals.totalTokens).toBe(110) expect(result.totals.uncachedTokens).toBe(30) + expect(result.byModel).toEqual([expect.objectContaining({ key: 'unknown' })]) + store.close() + }) + + it('preserves event-level models across model switches and epoch rebuilds', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession( + 'codex-model-switch-test', + { path: '/tmp', host: 'test', flavor: 'codex' }, + null, + 'default', + 'initial-model' + ) + + addAgentMessage(store, session.id, { + type: 'codex', + data: { + type: 'token_count', + model: 'old-model', + thread_id: 'thread-1', + turn_id: 'turn-1', + info: { + total_token_usage: { input_tokens: 100, output_tokens: 10 }, + last_token_usage: { input_tokens: 100, output_tokens: 10 } + } + } + }) + addAgentMessage(store, session.id, { + type: 'codex', + data: { + type: 'token_count', + model: 'new-model', + thread_id: 'thread-1', + turn_id: 'turn-2', + info: { + total_token_usage: { input_tokens: 140, output_tokens: 15 }, + last_token_usage: { input_tokens: 40, output_tokens: 5 } + } + } + }) + store.sessions.setSessionModel(session.id, 'latest-session-model', 'default') + + const expectedModels = [ + expect.objectContaining({ key: 'old-model', totalTokens: 110 }), + expect.objectContaining({ key: 'new-model', totalTokens: 45 }) + ] + expect(getUsageSummary(store, 'default', 'all').byModel).toEqual(expectedModels) + + store.messages.bumpMessageEpoch(session.id) + expect(getUsageSummary(store, 'default', 'all').byModel).toEqual(expectedModels) + store.close() + }) + + it('buckets daily usage using positive and negative viewer offsets', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession( + 'timezone-usage-test', + { path: '/tmp', host: 'test', flavor: 'claude' }, + null, + 'default' + ) + const usage = (id: string) => ({ + type: 'output', + data: { + type: 'assistant', + message: { id, usage: { input_tokens: 10, output_tokens: 2 } } + } + }) + addAgentMessage(store, session.id, usage('after-utc-midnight'), Date.parse('2026-01-15T00:30:00Z')) + addAgentMessage(store, session.id, usage('before-utc-midnight'), Date.parse('2026-01-15T23:30:00Z')) + + expect(getUsageSummary(store, 'default', 'all', 60).daily.map((row) => row.key)).toEqual([ + '2026-01-14', + '2026-01-15' + ]) + expect(getUsageSummary(store, 'default', 'all', -60).daily.map((row) => row.key)).toEqual([ + '2026-01-15', + '2026-01-16' + ]) store.close() }) diff --git a/hub/src/sync/usageService.ts b/hub/src/sync/usageService.ts index fcee6398..f6786e3a 100644 --- a/hub/src/sync/usageService.ts +++ b/hub/src/sync/usageService.ts @@ -31,10 +31,6 @@ function sessionAgent(session: StoredSession): string { return typeof flavor === 'string' && flavor.trim() ? flavor.trim() : 'unknown' } -function sessionModel(session: StoredSession): string | null { - return typeof session.model === 'string' && session.model.trim() ? session.model.trim() : null -} - function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageEvent | null { const envelope = asRecord(message.content) if (envelope?.role !== 'agent') return null @@ -58,7 +54,7 @@ function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageE const providerId = typeof assistant?.id === 'string' ? assistant.id : message.id const model = typeof assistant?.model === 'string' && assistant.model.trim() ? assistant.model.trim() - : sessionModel(session) + : null return { sessionId: session.id, sourceKey: `claude|${providerId}`, @@ -124,6 +120,9 @@ function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageE : typeof data.turn_id === 'string' ? data.turn_id : '' + const model = typeof data.model === 'string' && data.model.trim() + ? data.model.trim() + : null return { sessionId: session.id, sourceKey: isCumulative @@ -141,7 +140,7 @@ function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageE sourceSeq: message.seq, createdAt: message.createdAt, agent, - model: sessionModel(session), + model, kind: isCumulative ? 'cumulative' : 'delta', inputTokens, outputTokens, @@ -225,11 +224,16 @@ function toBucket(key: string, totals: Totals): UsageSummaryBucket { return { key, ...totals } } -function dayKey(timestamp: number): string { - return new Date(timestamp).toISOString().slice(0, 10) +function dayKey(timestamp: number, timezoneOffset: number): string { + return new Date(timestamp - timezoneOffset * 60_000).toISOString().slice(0, 10) } -export function getUsageSummary(store: Store, namespace: string, range: string | undefined): UsageSummaryResponse { +export function getUsageSummary( + store: Store, + namespace: string, + range: string | undefined, + timezoneOffset: number = 0 +): UsageSummaryResponse { const sessions = store.sessions.getSessionsByNamespace(namespace) // This is intentionally lazy. Existing HAPI databases have no usage table; // the first dashboard request backfills history, while later requests only @@ -289,9 +293,10 @@ export function getUsageSummary(store: Store, namespace: string, range: string | ? inputTokens + cacheReadTokens + cacheCreationTokens : inputTokens addTotals(totals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) - const dailyTotals = daily.get(dayKey(event.createdAt)) ?? emptyTotals() + const eventDayKey = dayKey(event.createdAt, timezoneOffset) + const dailyTotals = daily.get(eventDayKey) ?? emptyTotals() addTotals(dailyTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) - daily.set(dayKey(event.createdAt), dailyTotals) + daily.set(eventDayKey, dailyTotals) const agentTotals = byAgent.get(event.agent) ?? emptyTotals() addTotals(agentTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) byAgent.set(event.agent, agentTotals) diff --git a/hub/src/web/routes/usage.test.ts b/hub/src/web/routes/usage.test.ts index 6a386dee..8833da74 100644 --- a/hub/src/web/routes/usage.test.ts +++ b/hub/src/web/routes/usage.test.ts @@ -42,4 +42,20 @@ describe('GET /api/usage/summary', () => { store.close() } }) + + it('validates positive and negative timezone offset bounds', async () => { + const store = new Store(':memory:') + try { + for (const offset of [-840, 840]) { + const response = await createApp(store, 'default').request(`/api/usage/summary?timezoneOffset=${offset}`) + expect(response.status).toBe(200) + } + for (const offset of [-841, 841, 1.5]) { + const response = await createApp(store, 'default').request(`/api/usage/summary?timezoneOffset=${offset}`) + expect(response.status).toBe(400) + } + } finally { + store.close() + } + }) }) diff --git a/hub/src/web/routes/usage.ts b/hub/src/web/routes/usage.ts index 73c6fc17..8f81219c 100644 --- a/hub/src/web/routes/usage.ts +++ b/hub/src/web/routes/usage.ts @@ -12,7 +12,12 @@ export function createUsageRoutes(store: Store): Hono { return c.json({ error: 'Usage summary is only available to the hub owner' }, 403) } const range = c.req.query('range') - const response: UsageSummaryResponse = getUsageSummary(store, c.get('namespace'), range) + const timezoneOffsetParam = c.req.query('timezoneOffset') + const timezoneOffset = timezoneOffsetParam === undefined ? 0 : Number(timezoneOffsetParam) + if (!Number.isInteger(timezoneOffset) || timezoneOffset < -840 || timezoneOffset > 840) { + return c.json({ error: 'timezoneOffset must be an integer between -840 and 840' }, 400) + } + const response: UsageSummaryResponse = getUsageSummary(store, c.get('namespace'), range, timezoneOffset) c.header('Cache-Control', 'no-store') return c.json(response) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d02cbbe1..bd93961c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -648,8 +648,15 @@ export class ApiClient { return await this.request('/api/storage/sqlite') } - async getUsageSummary(range: '7d' | '30d' | 'all' = '7d'): Promise { - return await this.request(`/api/usage/summary?range=${encodeURIComponent(range)}`) + async getUsageSummary( + range: '7d' | '30d' | 'all' = '7d', + timezoneOffset: number = new Date().getTimezoneOffset() + ): Promise { + const params = new URLSearchParams({ + range, + timezoneOffset: String(timezoneOffset) + }) + return await this.request(`/api/usage/summary?${params.toString()}`) } async listMachineDirectory( diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index ec40c254..a48ac53b 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -4,7 +4,7 @@ export const queryKeys = { messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, sqliteStorage: ['sqlite-storage'] as const, - usageSummary: (range: string) => ['usage-summary', range] as const, + usageSummary: (range: string, timezoneOffset: number) => ['usage-summary', range, timezoneOffset] as const, machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const, diff --git a/web/src/routes/settings/usage.tsx b/web/src/routes/settings/usage.tsx index 5b1347c0..37774c1b 100644 --- a/web/src/routes/settings/usage.tsx +++ b/web/src/routes/settings/usage.tsx @@ -47,11 +47,12 @@ export default function SettingsUsagePage() { const { api } = useAppContext() const { t } = useTranslation() const [range, setRange] = useState('7d') + const [timezoneOffset] = useState(() => new Date().getTimezoneOffset()) const query = useQuery({ - queryKey: queryKeys.usageSummary(range), + queryKey: queryKeys.usageSummary(range, timezoneOffset), queryFn: async () => { if (!api) throw new Error('API unavailable') - return await api.getUsageSummary(range) + return await api.getUsageSummary(range, timezoneOffset) }, enabled: Boolean(api), staleTime: 30_000,