diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 2e02f3029..81f30b7db 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -55,7 +55,7 @@ import { type PersistedUsageEntry, } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; +import { parseRange, parseUsageSurface, rangeWindow, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; @@ -102,24 +102,16 @@ function nextLocalMidnight(now: number): number { } function usageSummaryExpiresAt( - entries: PersistedUsageEntry[], - range: UsageRange, - surface: UsageSurface, + _entries: PersistedUsageEntry[], + _range: UsageRange, + _surface: UsageSurface, now: number, ): number { - let expiresAt = nextLocalMidnight(now); - const windowMs = range === "7d" ? 7 * USAGE_DAY_MS : range === "30d" ? 30 * USAGE_DAY_MS : null; - if (windowMs === null) return expiresAt; - for (const entry of entries) { - if (!usageEntryMatchesSurface(entry, surface)) continue; - const expiry = entry.timestamp + windowMs; - if (expiry > now && expiry < expiresAt) expiresAt = expiry; - } - return expiresAt; + return nextLocalMidnight(now); } function refreshedUsageSummary(summary: T, range: UsageRange, now: number): T { - const since = range === "7d" ? now - 7 * USAGE_DAY_MS : range === "30d" ? now - 30 * USAGE_DAY_MS : null; + const { since } = rangeWindow(range, now); return { ...summary, since, generatedAt: now }; } diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 65f91be10..991d54e87 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -136,9 +136,23 @@ export function parseUsageSurface(input: string | null | undefined): UsageSurfac return "all"; } -function rangeWindow(range: UsageRange, now: number): { since: number | null; days: number } { - if (range === "7d") return { since: now - 7 * DAY_MS, days: 7 }; - if (range === "30d") return { since: now - 30 * DAY_MS, days: 30 }; +function startOfLocalDay(ts: number): number { + const d = new Date(ts); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +export function rangeWindow(range: UsageRange, now: number): { since: number | null; days: number } { + if (range === "7d") { + const start = new Date(startOfLocalDay(now)); + start.setDate(start.getDate() - 6); + return { since: start.getTime(), days: 7 }; + } + if (range === "30d") { + const start = new Date(startOfLocalDay(now)); + start.setDate(start.getDate() - 29); + return { since: start.getTime(), days: 30 }; + } return { since: null, days: 0 }; } @@ -347,8 +361,11 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr m.attemptCount += 1; m.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; }; + const startOfToday = startOfLocalDay(now); for (let i = days - 1; i >= 0; i--) { - const key = localDateKey(now - i * DAY_MS); + const d = new Date(startOfToday); + d.setDate(d.getDate() - i); + const key = localDateKey(d.getTime()); grid.set(key, { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, models: [] }); } for (const entry of entries) { diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 52db5bb0a..3e0441bdf 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -852,4 +852,73 @@ describe("summarizeUsage", () => { }); }); + test("7d and 30d range windows align to calendar day boundaries (00:00:00) so completed days remain stable (#1580)", () => { + // Construct local midnight for 2026-08-13 + const todayMidnight = new Date(2026, 7, 13, 0, 0, 0, 0).getTime(); + const dayMs = 86_400_000; + + // Day -29 (2026-07-15) at 04:00 AM (for 30d boundary) + const dayMinus29Date = new Date(todayMidnight); + dayMinus29Date.setDate(dayMinus29Date.getDate() - 29); + dayMinus29Date.setHours(4, 0, 0, 0); + const dayMinus29Ts = dayMinus29Date.getTime(); + + // Day -6 (2026-08-07) at 02:30 AM (for 7d boundary) + const dayMinus6Early = todayMidnight - 6 * dayMs + 2.5 * 3600_000; + // Yesterday (2026-08-12) at 03:00 AM + const yesterdayEarly = todayMidnight - 1 * dayMs + 3 * 3600_000; + // Yesterday (2026-08-12) at 19:00 PM + const yesterdayLate = todayMidnight - 1 * dayMs + 19 * 3600_000; + // Today (2026-08-13) at 08:00 AM + const todayMorning = todayMidnight + 8 * 3600_000; + + const entries: PersistedUsageEntry[] = [ + entry({ ts: dayMinus29Ts, usageStatus: "reported", usage: { inputTokens: 600, outputTokens: 600 }, totalTokens: 1200 }), + entry({ ts: dayMinus6Early, usageStatus: "reported", usage: { inputTokens: 250, outputTokens: 250 }, totalTokens: 500 }), + entry({ ts: yesterdayEarly, usageStatus: "reported", usage: { inputTokens: 400, outputTokens: 400 }, totalTokens: 800 }), + entry({ ts: yesterdayLate, usageStatus: "reported", usage: { inputTokens: 100, outputTokens: 100 }, totalTokens: 200 }), + entry({ ts: todayMorning, usageStatus: "reported", usage: { inputTokens: 150, outputTokens: 150 }, totalTokens: 300 }), + ]; + + // Summary at 09:00 AM today (7d) + const sumMorning7d = summarizeUsage(entries, "7d", todayMidnight + 9 * 3600_000); + const day6Morning = sumMorning7d.days.find(d => d.date.endsWith("08-07"))?.totalTokens; + const yesterdayMorningTotal = sumMorning7d.days.find(d => d.date.endsWith("08-12"))?.totalTokens; + + expect(day6Morning).toBe(500); + expect(yesterdayMorningTotal).toBe(1000); + + // Summary at 09:00 AM today (30d) + const sumMorning30d = summarizeUsage(entries, "30d", todayMidnight + 9 * 3600_000); + const day29Morning = sumMorning30d.days.find(d => d.date.endsWith("07-15"))?.totalTokens; + expect(sumMorning30d.days).toHaveLength(30); + expect(day29Morning).toBe(1200); + + // Summary at 23:30 PM today (later in the day) with an additional turn today + const todayEvening = todayMidnight + 20 * 3600_000; + const entriesLater = [ + ...entries, + entry({ ts: todayEvening, usageStatus: "reported", usage: { inputTokens: 50, outputTokens: 50 }, totalTokens: 100 }), + ]; + + // 7d evening + const sumEvening7d = summarizeUsage(entriesLater, "7d", todayMidnight + 23.5 * 3600_000); + const day6Evening = sumEvening7d.days.find(d => d.date.endsWith("08-07"))?.totalTokens; + const yesterdayEveningTotal = sumEvening7d.days.find(d => d.date.endsWith("08-12"))?.totalTokens; + const todayEveningTotal = sumEvening7d.days.find(d => d.date.endsWith("08-13"))?.totalTokens; + + // Critical assertion: completed days NEVER lose tokens as hours progress in 7d + expect(day6Evening).toBe(500); + expect(yesterdayEveningTotal).toBe(1000); + expect(todayEveningTotal).toBe(400); // 300 + 100 + expect(sumMorning7d.since).toBe(sumEvening7d.since); + + // 30d evening + const sumEvening30d = summarizeUsage(entriesLater, "30d", todayMidnight + 23.5 * 3600_000); + const day29Evening = sumEvening30d.days.find(d => d.date.endsWith("07-15"))?.totalTokens; + expect(sumEvening30d.days).toHaveLength(30); + expect(day29Evening).toBe(1200); + expect(sumMorning30d.since).toBe(sumEvening30d.since); + }); + });