Skip to content

Commit 4a5b3fd

Browse files
committed
fix(gateway): keep usage ranges on calendar days
1 parent b715b4c commit 4a5b3fd

3 files changed

Lines changed: 77 additions & 82 deletions

File tree

src/gateway/server-methods/usage.cost-usage-cache.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Regression: costUsageCache (usage.ts:65) has no production delete/prune/evict
22
// path. The TTL at L310 is read-only — on a miss after expiry, set() overwrites
3-
// the same key but never removes stale keys. parseDateRange derives cacheKey
4-
// from getTodayStartMs so cacheKey rolls at every UTC 00:00, and additional
3+
// the same key but never removes stale keys. resolveDateRange derives cacheKey
4+
// from the current calendar day so cacheKey rolls at every UTC 00:00, and additional
55
// axes (days, startDate, endDate, utcOffset) multiply cardinality.
66
//
77
// The same file has three sibling caches that implement MAX + FIFO eviction

src/gateway/server-methods/usage.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,21 @@ describe("gateway usage helpers", () => {
290290
});
291291
});
292292

293+
it("resolveDateRange keeps trailing gateway ranges on calendar days across DST", () => {
294+
withTimeZone("America/New_York", () => {
295+
vi.useFakeTimers();
296+
vi.setSystemTime(new Date("2026-03-09T12:00:00.000Z"));
297+
const range = expectDateRange(
298+
testApi.resolveDateRange({
299+
days: 2,
300+
mode: "gateway",
301+
}),
302+
);
303+
expect(range.startMs).toBe(new Date(2026, 2, 8).getTime());
304+
expect(range.endMs).toBe(new Date(2026, 2, 10).getTime() - 1);
305+
});
306+
});
307+
293308
it("resolveDateRange clamps days to at least 1 and defaults to 30 days", () => {
294309
vi.useFakeTimers();
295310
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));

src/gateway/server-methods/usage.ts

Lines changed: 60 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ import type { GatewayRequestHandlers, RespondFn } from "./types.js";
6565
const COST_USAGE_CACHE_TTL_MS = 30_000;
6666
const COST_USAGE_CACHE_MAX = 256;
6767
const SESSIONS_USAGE_AGENT_LOAD_CONCURRENCY = 12;
68-
const DAY_MS = 24 * 60 * 60 * 1000;
6968

7069
type DateRange = { startMs: number; endMs: number };
7170
// Keep validation and parsed timestamps in one result so handlers cannot forward
@@ -169,21 +168,28 @@ const parseDateParts = (raw: unknown): DateParts | undefined => {
169168
return { year, monthIndex, day };
170169
};
171170

172-
const datePartsToEndMs = (parts: DateParts, interpretation: DateInterpretation): number => {
171+
const shiftDateParts = (parts: DateParts, days: number): DateParts => {
172+
const shifted = new Date(Date.UTC(parts.year, parts.monthIndex, parts.day + days));
173+
return {
174+
year: shifted.getUTCFullYear(),
175+
monthIndex: shifted.getUTCMonth(),
176+
day: shifted.getUTCDate(),
177+
};
178+
};
179+
180+
const datePartsToStartMs = (parts: DateParts, interpretation: DateInterpretation): number => {
173181
const { year, monthIndex, day } = parts;
174182
if (interpretation.mode === "gateway") {
175-
return new Date(year, monthIndex, day + 1).getTime() - 1;
183+
return new Date(year, monthIndex, day).getTime();
176184
}
177185
if (interpretation.mode === "specific") {
178-
return Date.UTC(year, monthIndex, day + 1) - interpretation.utcOffsetMinutes * 60 * 1000 - 1;
186+
return Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000;
179187
}
180-
return Date.UTC(year, monthIndex, day + 1) - 1;
188+
return Date.UTC(year, monthIndex, day);
181189
};
182190

183-
const parseDateEndToMs = (raw: unknown, interpretation: DateInterpretation): number | undefined => {
184-
const parts = parseDateParts(raw);
185-
return parts ? datePartsToEndMs(parts, interpretation) : undefined;
186-
};
191+
const datePartsToEndMs = (parts: DateParts, interpretation: DateInterpretation): number =>
192+
datePartsToStartMs(shiftDateParts(parts, 1), interpretation) - 1;
187193

188194
// usage.cost / sessions.usage accept optional startDate/endDate. parseDateParts returns
189195
// undefined for both absent and invalid input, so an explicitly supplied but unparseable
@@ -258,6 +264,25 @@ const resolveDayBucketUtcOffsetMinutes = (interpretation: DateInterpretation) =>
258264
? interpretation.utcOffsetMinutes
259265
: 0;
260266

267+
const getDateParts = (date: Date, interpretation: DateInterpretation): DateParts => {
268+
if (interpretation.mode === "gateway") {
269+
return { year: date.getFullYear(), monthIndex: date.getMonth(), day: date.getDate() };
270+
}
271+
if (interpretation.mode === "specific") {
272+
const shifted = new Date(date.getTime() + interpretation.utcOffsetMinutes * 60 * 1000);
273+
return {
274+
year: shifted.getUTCFullYear(),
275+
monthIndex: shifted.getUTCMonth(),
276+
day: shifted.getUTCDate(),
277+
};
278+
}
279+
return {
280+
year: date.getUTCFullYear(),
281+
monthIndex: date.getUTCMonth(),
282+
day: date.getUTCDate(),
283+
};
284+
};
285+
261286
/**
262287
* Parse a date string (YYYY-MM-DD) to start-of-day timestamp based on interpretation mode.
263288
* Returns undefined if invalid.
@@ -270,53 +295,17 @@ const parseDateToMs = (
270295
if (!parts) {
271296
return undefined;
272297
}
273-
const { year, monthIndex, day } = parts;
274-
if (interpretation.mode === "gateway") {
275-
const ms = new Date(year, monthIndex, day).getTime();
276-
return Number.isNaN(ms) ? undefined : ms;
277-
}
278-
if (interpretation.mode === "specific") {
279-
const ms = Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000;
280-
return Number.isNaN(ms) ? undefined : ms;
281-
}
282-
const ms = Date.UTC(year, monthIndex, day);
283-
return Number.isNaN(ms) ? undefined : ms;
284-
};
285-
286-
const getTodayStartMs = (now: Date, interpretation: DateInterpretation): number => {
287-
if (interpretation.mode === "gateway") {
288-
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
289-
}
290-
if (interpretation.mode === "specific") {
291-
const shifted = new Date(now.getTime() + interpretation.utcOffsetMinutes * 60 * 1000);
292-
return (
293-
Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) -
294-
interpretation.utcOffsetMinutes * 60 * 1000
295-
);
296-
}
297-
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
298+
return datePartsToStartMs(parts, interpretation);
298299
};
299300

300301
const formatDateLabel = (ms: number, interpretation: DateInterpretation): string => {
301-
const date = new Date(ms);
302-
if (interpretation.mode === "gateway") {
303-
return formatDateParts(date.getFullYear(), date.getMonth(), date.getDate());
304-
}
305-
if (interpretation.mode === "specific") {
306-
const shifted = new Date(ms + interpretation.utcOffsetMinutes * 60 * 1000);
307-
return formatDateParts(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
308-
}
309-
return formatDateParts(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
302+
const parts = getDateParts(new Date(ms), interpretation);
303+
return formatDateParts(parts.year, parts.monthIndex, parts.day);
310304
};
311305

312306
const formatDateParts = (year: number, monthIndex: number, day: number): string =>
313307
`${year}-${String(monthIndex + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
314308

315-
const formatExplicitDateLabel = (raw: unknown): string | undefined => {
316-
const parts = parseDateParts(raw);
317-
return parts ? formatDateParts(parts.year, parts.monthIndex, parts.day) : undefined;
318-
};
319-
320309
const parseDays = (raw: unknown): number | undefined => {
321310
if (typeof raw === "number" && Number.isFinite(raw)) {
322311
return Math.floor(raw);
@@ -349,6 +338,15 @@ const resolveRangeDays = (raw: unknown): number | "all" | undefined => {
349338
return undefined;
350339
};
351340

341+
const resolveTrailingDays = (
342+
endDateParts: DateParts,
343+
days: number,
344+
interpretation: DateInterpretation,
345+
): DateRange => ({
346+
startMs: datePartsToStartMs(shiftDateParts(endDateParts, -(days - 1)), interpretation),
347+
endMs: datePartsToEndMs(endDateParts, interpretation),
348+
});
349+
352350
/**
353351
* Get date range from params (startDate/endDate or days).
354352
* Falls back to last 30 days if not provided.
@@ -371,45 +369,37 @@ const resolveDateRange = (params: {
371369

372370
const now = new Date();
373371
const interpretation = resolveDateInterpretation(params);
374-
const todayStartMs = getTodayStartMs(now, interpretation);
375-
const todayEndMs = todayStartMs + DAY_MS - 1;
372+
const todayDateParts = getDateParts(now, interpretation);
373+
const todayEndMs = datePartsToEndMs(todayDateParts, interpretation);
376374

377-
const startMs = parseDateToMs(params.startDate, interpretation);
378-
const endMs = parseDateToMs(params.endDate, interpretation);
375+
const startDateParts = parseDateParts(params.startDate);
376+
const endDateParts = parseDateParts(params.endDate);
379377

380-
if (startMs !== undefined && endMs !== undefined) {
381-
if (startMs > endMs) {
378+
if (startDateParts && endDateParts) {
379+
const startMs = datePartsToStartMs(startDateParts, interpretation);
380+
const endStartMs = datePartsToStartMs(endDateParts, interpretation);
381+
if (startMs > endStartMs) {
382382
return { ok: false, error: "startDate must not be after endDate" };
383383
}
384-
const endOfDayMs = parseDateEndToMs(params.endDate, interpretation);
385-
if (endOfDayMs === undefined) {
386-
return {
387-
ok: false,
388-
error: "invalid endDate: expected a valid YYYY-MM-DD calendar date",
389-
};
390-
}
391-
return { ok: true, value: { startMs, endMs: endOfDayMs } };
384+
return { ok: true, value: { startMs, endMs: datePartsToEndMs(endDateParts, interpretation) } };
392385
}
393386

394387
const rangeDays = resolveRangeDays(params.range);
395388
if (rangeDays === "all") {
396389
return { ok: true, value: { startMs: 0, endMs: todayEndMs } };
397390
}
398391
if (rangeDays !== undefined) {
399-
const start = todayStartMs - (rangeDays - 1) * DAY_MS;
400-
return { ok: true, value: { startMs: start, endMs: todayEndMs } };
392+
return { ok: true, value: resolveTrailingDays(todayDateParts, rangeDays, interpretation) };
401393
}
402394

403395
const days = parseDays(params.days);
404396
if (days !== undefined) {
405397
const clampedDays = Math.max(1, days);
406-
const start = todayStartMs - (clampedDays - 1) * DAY_MS;
407-
return { ok: true, value: { startMs: start, endMs: todayEndMs } };
398+
return { ok: true, value: resolveTrailingDays(todayDateParts, clampedDays, interpretation) };
408399
}
409400

410401
// Default to last 30 days
411-
const defaultStartMs = todayStartMs - 29 * DAY_MS;
412-
return { ok: true, value: { startMs: defaultStartMs, endMs: todayEndMs } };
402+
return { ok: true, value: resolveTrailingDays(todayDateParts, 30, interpretation) };
413403
};
414404

415405
type DiscoveredSessionWithAgent = DiscoveredSession & { agentId: string };
@@ -945,7 +935,6 @@ export const testApi = {
945935
parseUtcOffsetToMinutes,
946936
resolveDateInterpretation,
947937
parseDateToMs,
948-
getTodayStartMs,
949938
parseDays,
950939
resolveDateRange,
951940
discoverAllSessionsForUsage,
@@ -1503,19 +1492,10 @@ export const usageHandlers: GatewayRequestHandlers = {
15031492
...tail,
15041493
};
15051494

1506-
const explicitStartDate = formatExplicitDateLabel(p.startDate);
1507-
const explicitEndDate = formatExplicitDateLabel(p.endDate);
1508-
let responseStartDate = formatDateLabel(startMs, dateInterpretation);
1509-
let responseEndDate = formatDateLabel(endMs, dateInterpretation);
1510-
if (explicitStartDate !== undefined && explicitEndDate !== undefined) {
1511-
responseStartDate = explicitStartDate;
1512-
responseEndDate = explicitEndDate;
1513-
}
1514-
15151495
const result: SessionsUsageResult = {
15161496
updatedAt: now,
1517-
startDate: responseStartDate,
1518-
endDate: responseEndDate,
1497+
startDate: formatDateLabel(startMs, dateInterpretation),
1498+
endDate: formatDateLabel(endMs, dateInterpretation),
15191499
sessions,
15201500
totals: aggregateTotals,
15211501
aggregates,

0 commit comments

Comments
 (0)