Issue 17774 - Usage - Local - Show data from midnight to midnight of selected dates for browser time zone (AI assisted) (openclaw#19357) thanks @huntharo

Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini (override approved by Tak for this run; local baseline failures outside PR scope)

Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Harold Hunt
2026-02-20 21:09:03 -05:00
committed by GitHub
parent 02ac5b59d1
commit 844d84a7f5
9 changed files with 635 additions and 40 deletions

View File

@@ -114,6 +114,12 @@ export const SessionsUsageParamsSchema = Type.Object(
startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
/** End date for range filter (YYYY-MM-DD). */
endDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
/** How start/end dates should be interpreted. Defaults to UTC when omitted. */
mode: Type.Optional(
Type.Union([Type.Literal("utc"), Type.Literal("gateway"), Type.Literal("specific")]),
),
/** UTC offset to use when mode is `specific` (for example, UTC-4 or UTC+5:30). */
utcOffset: Type.Optional(Type.String({ pattern: "^UTC[+-]\\d{1,2}(?::[0-5]\\d)?$" })),
/** Maximum sessions to return (default 50). */
limit: Type.Optional(Type.Integer({ minimum: 1 })),
/** Include context weight breakdown (systemPromptReport). */

View File

@@ -21,6 +21,8 @@ import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
import { __test } from "./usage.js";
describe("gateway usage helpers", () => {
const dayMs = 24 * 60 * 60 * 1000;
beforeEach(() => {
__test.costUsageCache.clear();
vi.useRealTimers();
@@ -35,6 +37,20 @@ describe("gateway usage helpers", () => {
expect(__test.parseDateToMs(undefined)).toBeUndefined();
});
it("parseUtcOffsetToMinutes supports whole-hour and half-hour offsets", () => {
expect(__test.parseUtcOffsetToMinutes("UTC-4")).toBe(-240);
expect(__test.parseUtcOffsetToMinutes("UTC+5:30")).toBe(330);
expect(__test.parseUtcOffsetToMinutes(" UTC+14 ")).toBe(14 * 60);
});
it("parseUtcOffsetToMinutes rejects invalid offsets", () => {
expect(__test.parseUtcOffsetToMinutes("UTC+14:30")).toBeUndefined();
expect(__test.parseUtcOffsetToMinutes("UTC+5:99")).toBeUndefined();
expect(__test.parseUtcOffsetToMinutes("UTC+25")).toBeUndefined();
expect(__test.parseUtcOffsetToMinutes("GMT+5")).toBeUndefined();
expect(__test.parseUtcOffsetToMinutes(undefined)).toBeUndefined();
});
it("parseDays coerces strings/numbers to integers", () => {
expect(__test.parseDays(7.9)).toBe(7);
expect(__test.parseDays("30")).toBe(30);
@@ -42,22 +58,84 @@ describe("gateway usage helpers", () => {
expect(__test.parseDays("nope")).toBeUndefined();
});
it("parseDateRange uses explicit start/end (inclusive end of day)", () => {
it("parseDateRange uses explicit start/end as UTC when mode is missing (backward compatible)", () => {
const range = __test.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" });
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + 24 * 60 * 60 * 1000 - 1);
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
});
it("parseDateRange uses explicit UTC mode", () => {
const range = __test.parseDateRange({
startDate: "2026-02-01",
endDate: "2026-02-02",
mode: "utc",
});
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
});
it("parseDateRange uses specific UTC offset for explicit dates", () => {
const range = __test.parseDateRange({
startDate: "2026-02-01",
endDate: "2026-02-02",
mode: "specific",
utcOffset: "UTC+5:30",
});
const start = Date.UTC(2026, 1, 1) - 5.5 * 60 * 60 * 1000;
const endStart = Date.UTC(2026, 1, 2) - 5.5 * 60 * 60 * 1000;
expect(range.startMs).toBe(start);
expect(range.endMs).toBe(endStart + dayMs - 1);
});
it("parseDateRange falls back to UTC when specific mode offset is missing or invalid", () => {
const missingOffset = __test.parseDateRange({
startDate: "2026-02-01",
endDate: "2026-02-02",
mode: "specific",
});
const invalidOffset = __test.parseDateRange({
startDate: "2026-02-01",
endDate: "2026-02-02",
mode: "specific",
utcOffset: "bad-value",
});
expect(missingOffset.startMs).toBe(Date.UTC(2026, 1, 1));
expect(missingOffset.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
expect(invalidOffset.startMs).toBe(Date.UTC(2026, 1, 1));
expect(invalidOffset.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
});
it("parseDateRange uses specific offset for today/day math after UTC midnight", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-17T03:57:00.000Z"));
const range = __test.parseDateRange({
days: 1,
mode: "specific",
utcOffset: "UTC-5",
});
expect(range.startMs).toBe(Date.UTC(2026, 1, 16, 5, 0, 0, 0));
expect(range.endMs).toBe(Date.UTC(2026, 1, 17, 4, 59, 59, 999));
});
it("parseDateRange uses gateway local day boundaries in gateway mode", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
const range = __test.parseDateRange({ days: 1, mode: "gateway" });
const expectedStart = new Date(2026, 1, 5).getTime();
expect(range.startMs).toBe(expectedStart);
expect(range.endMs).toBe(expectedStart + dayMs - 1);
});
it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
const oneDay = __test.parseDateRange({ days: 0 });
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + dayMs - 1);
expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5));
const def = __test.parseDateRange({});
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * 24 * 60 * 60 * 1000);
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + dayMs - 1);
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * dayMs);
});
it("loadCostUsageSummaryCached caches within TTL", async () => {

View File

@@ -39,8 +39,12 @@ import {
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
const COST_USAGE_CACHE_TTL_MS = 30_000;
const DAY_MS = 24 * 60 * 60 * 1000;
type DateRange = { startMs: number; endMs: number };
type DateInterpretation =
| { mode: "utc" | "gateway" }
| { mode: "specific"; utcOffsetMinutes: number };
type CostUsageCacheEntry = {
summary?: CostUsageSummary;
@@ -84,11 +88,9 @@ function resolveSessionUsageFileOrRespond(
return { config, entry, agentId, sessionId, sessionFile };
}
/**
* Parse a date string (YYYY-MM-DD) to start of day timestamp in UTC.
* Returns undefined if invalid.
*/
const parseDateToMs = (raw: unknown): number | undefined => {
const parseDateParts = (
raw: unknown,
): { year: number; monthIndex: number; day: number } | undefined => {
if (typeof raw !== "string" || !raw.trim()) {
return undefined;
}
@@ -96,13 +98,98 @@ const parseDateToMs = (raw: unknown): number | undefined => {
if (!match) {
return undefined;
}
const [, year, month, day] = match;
// Use UTC to ensure consistent behavior across timezones
const ms = Date.UTC(parseInt(year), parseInt(month) - 1, parseInt(day));
if (Number.isNaN(ms)) {
const [, yearStr, monthStr, dayStr] = match;
const year = Number(yearStr);
const monthIndex = Number(monthStr) - 1;
const day = Number(dayStr);
if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || !Number.isFinite(day)) {
return undefined;
}
return ms;
return { year, monthIndex, day };
};
/**
* Parse a UTC offset string in the format UTC+H, UTC-H, UTC+HH, UTC-HH, UTC+H:MM, UTC-HH:MM.
* Returns the UTC offset in minutes (east-positive), or undefined if invalid.
*/
const parseUtcOffsetToMinutes = (raw: unknown): number | undefined => {
if (typeof raw !== "string" || !raw.trim()) {
return undefined;
}
const match = /^UTC([+-])(\d{1,2})(?::([0-5]\d))?$/.exec(raw.trim());
if (!match) {
return undefined;
}
const sign = match[1] === "+" ? 1 : -1;
const hours = Number(match[2]);
const minutes = Number(match[3] ?? "0");
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) {
return undefined;
}
if (hours > 14 || (hours === 14 && minutes !== 0)) {
return undefined;
}
const totalMinutes = sign * (hours * 60 + minutes);
if (totalMinutes < -12 * 60 || totalMinutes > 14 * 60) {
return undefined;
}
return totalMinutes;
};
const resolveDateInterpretation = (params: {
mode?: unknown;
utcOffset?: unknown;
}): DateInterpretation => {
if (params.mode === "gateway") {
return { mode: "gateway" };
}
if (params.mode === "specific") {
const utcOffsetMinutes = parseUtcOffsetToMinutes(params.utcOffset);
if (utcOffsetMinutes !== undefined) {
return { mode: "specific", utcOffsetMinutes };
}
}
// Backward compatibility: when mode is missing (or invalid), keep current UTC interpretation.
return { mode: "utc" };
};
/**
* Parse a date string (YYYY-MM-DD) to start-of-day timestamp based on interpretation mode.
* Returns undefined if invalid.
*/
const parseDateToMs = (
raw: unknown,
interpretation: DateInterpretation = { mode: "utc" },
): number | undefined => {
const parts = parseDateParts(raw);
if (!parts) {
return undefined;
}
const { year, monthIndex, day } = parts;
if (interpretation.mode === "gateway") {
const ms = new Date(year, monthIndex, day).getTime();
return Number.isNaN(ms) ? undefined : ms;
}
if (interpretation.mode === "specific") {
const ms = Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000;
return Number.isNaN(ms) ? undefined : ms;
}
const ms = Date.UTC(year, monthIndex, day);
return Number.isNaN(ms) ? undefined : ms;
};
const getTodayStartMs = (now: Date, interpretation: DateInterpretation): number => {
if (interpretation.mode === "gateway") {
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
if (interpretation.mode === "specific") {
const shifted = new Date(now.getTime() + interpretation.utcOffsetMinutes * 60 * 1000);
return (
Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) -
interpretation.utcOffsetMinutes * 60 * 1000
);
}
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
};
const parseDays = (raw: unknown): number | undefined => {
@@ -126,29 +213,31 @@ const parseDateRange = (params: {
startDate?: unknown;
endDate?: unknown;
days?: unknown;
mode?: unknown;
utcOffset?: unknown;
}): DateRange => {
const now = new Date();
// Use UTC for consistent date handling
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const todayEndMs = todayStartMs + 24 * 60 * 60 * 1000 - 1;
const interpretation = resolveDateInterpretation(params);
const todayStartMs = getTodayStartMs(now, interpretation);
const todayEndMs = todayStartMs + DAY_MS - 1;
const startMs = parseDateToMs(params.startDate);
const endMs = parseDateToMs(params.endDate);
const startMs = parseDateToMs(params.startDate, interpretation);
const endMs = parseDateToMs(params.endDate, interpretation);
if (startMs !== undefined && endMs !== undefined) {
// endMs should be end of day
return { startMs, endMs: endMs + 24 * 60 * 60 * 1000 - 1 };
return { startMs, endMs: endMs + DAY_MS - 1 };
}
const days = parseDays(params.days);
if (days !== undefined) {
const clampedDays = Math.max(1, days);
const start = todayStartMs - (clampedDays - 1) * 24 * 60 * 60 * 1000;
const start = todayStartMs - (clampedDays - 1) * DAY_MS;
return { startMs: start, endMs: todayEndMs };
}
// Default to last 30 days
const defaultStartMs = todayStartMs - 29 * 24 * 60 * 60 * 1000;
const defaultStartMs = todayStartMs - 29 * DAY_MS;
return { startMs: defaultStartMs, endMs: todayEndMs };
};
@@ -239,7 +328,11 @@ async function loadCostUsageSummaryCached(params: {
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
export const __test = {
parseDateParts,
parseUtcOffsetToMinutes,
resolveDateInterpretation,
parseDateToMs,
getTodayStartMs,
parseDays,
parseDateRange,
discoverAllSessionsForUsage,
@@ -313,6 +406,8 @@ export const usageHandlers: GatewayRequestHandlers = {
startDate: params?.startDate,
endDate: params?.endDate,
days: params?.days,
mode: params?.mode,
utcOffset: params?.utcOffset,
});
const summary = await loadCostUsageSummaryCached({ startMs, endMs, config });
respond(true, summary, undefined);
@@ -335,6 +430,8 @@ export const usageHandlers: GatewayRequestHandlers = {
const { startMs, endMs } = parseDateRange({
startDate: p.startDate,
endDate: p.endDate,
mode: p.mode,
utcOffset: p.utcOffset,
});
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const includeContextWeight = p.includeContextWeight ?? false;