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

@@ -73,6 +73,10 @@ export function renderUsageTab(state: AppViewState) {
onRefresh: () => loadUsage(state),
onTimeZoneChange: (zone) => {
state.usageTimeZone = zone;
state.usageSelectedDays = [];
state.usageSelectedHours = [];
state.usageSelectedSessions = [];
void loadUsage(state);
},
onToggleContextExpanded: () => {
state.usageContextExpanded = !state.usageContextExpanded;

View File

@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __test, loadUsage, type UsageState } from "./usage.ts";
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
function createState(request: RequestFn, overrides: Partial<UsageState> = {}): UsageState {
return {
client: { request } as unknown as UsageState["client"],
connected: true,
usageLoading: false,
usageResult: null,
usageCostSummary: null,
usageError: null,
usageStartDate: "2026-02-16",
usageEndDate: "2026-02-16",
usageSelectedSessions: [],
usageSelectedDays: [],
usageTimeSeries: null,
usageTimeSeriesLoading: false,
usageTimeSeriesCursorStart: null,
usageTimeSeriesCursorEnd: null,
usageSessionLogs: null,
usageSessionLogsLoading: false,
usageTimeZone: "local",
...overrides,
};
}
describe("usage controller date interpretation params", () => {
beforeEach(() => {
__test.resetLegacyUsageDateParamsCache();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("formats UTC offsets for whole and half-hour timezones", () => {
expect(__test.formatUtcOffset(240)).toBe("UTC-4");
expect(__test.formatUtcOffset(-330)).toBe("UTC+5:30");
expect(__test.formatUtcOffset(0)).toBe("UTC+0");
});
it("sends specific mode with browser offset when usage timezone is local", async () => {
const request = vi.fn(async () => ({}));
const state = createState(request, { usageTimeZone: "local" });
vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330);
await loadUsage(state);
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
startDate: "2026-02-16",
endDate: "2026-02-16",
mode: "specific",
utcOffset: "UTC+5:30",
limit: 1000,
includeContextWeight: true,
});
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
startDate: "2026-02-16",
endDate: "2026-02-16",
mode: "specific",
utcOffset: "UTC+5:30",
});
});
it("sends utc mode without offset when usage timezone is utc", async () => {
const request = vi.fn(async () => ({}));
const state = createState(request, { usageTimeZone: "utc" });
await loadUsage(state);
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
startDate: "2026-02-16",
endDate: "2026-02-16",
mode: "utc",
limit: 1000,
includeContextWeight: true,
});
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
startDate: "2026-02-16",
endDate: "2026-02-16",
mode: "utc",
});
});
it("captures useful error strings in loadUsage", async () => {
const request = vi.fn(async () => {
throw new Error("request failed");
});
const state = createState(request);
await loadUsage(state);
expect(state.usageError).toBe("request failed");
});
it("serializes non-Error objects without object-to-string coercion", () => {
expect(__test.toErrorMessage({ reason: "nope" })).toBe('{"reason":"nope"}');
});
it("falls back and remembers compatibility when sessions.usage rejects mode/utcOffset", async () => {
const storage = createStorageMock();
vi.stubGlobal("localStorage", storage as unknown as Storage);
vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330);
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "sessions.usage") {
const record = (params ?? {}) as Record<string, unknown>;
if ("mode" in record || "utcOffset" in record) {
throw new Error(
"invalid sessions.usage params: at root: unexpected property 'mode'; at root: unexpected property 'utcOffset'",
);
}
return { sessions: [] };
}
return {};
});
const state = createState(request, {
usageTimeZone: "local",
settings: { gatewayUrl: "ws://127.0.0.1:18789" },
});
await loadUsage(state);
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
startDate: "2026-02-16",
endDate: "2026-02-16",
mode: "specific",
utcOffset: "UTC+5:30",
limit: 1000,
includeContextWeight: true,
});
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
startDate: "2026-02-16",
endDate: "2026-02-16",
mode: "specific",
utcOffset: "UTC+5:30",
});
expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", {
startDate: "2026-02-16",
endDate: "2026-02-16",
limit: 1000,
includeContextWeight: true,
});
expect(request).toHaveBeenNthCalledWith(4, "usage.cost", {
startDate: "2026-02-16",
endDate: "2026-02-16",
});
// Subsequent loads for the same gateway should skip mode/utcOffset immediately.
await loadUsage(state);
expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", {
startDate: "2026-02-16",
endDate: "2026-02-16",
limit: 1000,
includeContextWeight: true,
});
expect(request).toHaveBeenNthCalledWith(6, "usage.cost", {
startDate: "2026-02-16",
endDate: "2026-02-16",
});
// Persisted flag should survive cache resets (simulating app reload).
__test.resetLegacyUsageDateParamsCache();
expect(__test.shouldSendLegacyDateInterpretation(state)).toBe(false);
vi.unstubAllGlobals();
});
});
function createStorageMock() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.get(key) ?? null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
};
}

View File

@@ -19,8 +19,169 @@ export type UsageState = {
usageTimeSeriesCursorEnd: number | null;
usageSessionLogs: SessionLogEntry[] | null;
usageSessionLogsLoading: boolean;
usageTimeZone: "local" | "utc";
settings?: { gatewayUrl?: string };
};
type DateInterpretationMode = "utc" | "gateway" | "specific";
type UsageDateInterpretationParams = {
mode: DateInterpretationMode;
utcOffset?: string;
};
const LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY = "openclaw.control.usage.date-params.v1";
const LEGACY_USAGE_DATE_PARAMS_DEFAULT_GATEWAY_KEY = "__default__";
const LEGACY_USAGE_DATE_PARAMS_MODE_RE = /unexpected property ['"]mode['"]/i;
const LEGACY_USAGE_DATE_PARAMS_OFFSET_RE = /unexpected property ['"]utcoffset['"]/i;
const LEGACY_USAGE_DATE_PARAMS_INVALID_RE = /invalid sessions\.usage params/i;
let legacyUsageDateParamsCache: Set<string> | null = null;
function getLocalStorage(): Storage | null {
// Support browser runtime and node tests (when localStorage is stubbed globally).
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
if (typeof localStorage !== "undefined") {
return localStorage;
}
return null;
}
function loadLegacyUsageDateParamsCache(): Set<string> {
const storage = getLocalStorage();
if (!storage) {
return new Set<string>();
}
try {
const raw = storage.getItem(LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY);
if (!raw) {
return new Set<string>();
}
const parsed = JSON.parse(raw) as { unsupportedGatewayKeys?: unknown } | null;
if (!parsed || !Array.isArray(parsed.unsupportedGatewayKeys)) {
return new Set<string>();
}
return new Set(
parsed.unsupportedGatewayKeys
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean),
);
} catch {
return new Set<string>();
}
}
function persistLegacyUsageDateParamsCache(cache: Set<string>) {
const storage = getLocalStorage();
if (!storage) {
return;
}
try {
storage.setItem(
LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY,
JSON.stringify({ unsupportedGatewayKeys: Array.from(cache) }),
);
} catch {
// ignore quota/private-mode failures
}
}
function getLegacyUsageDateParamsCache(): Set<string> {
if (!legacyUsageDateParamsCache) {
legacyUsageDateParamsCache = loadLegacyUsageDateParamsCache();
}
return legacyUsageDateParamsCache;
}
function normalizeGatewayCompatibilityKey(gatewayUrl?: string): string {
const trimmed = gatewayUrl?.trim();
if (!trimmed) {
return LEGACY_USAGE_DATE_PARAMS_DEFAULT_GATEWAY_KEY;
}
try {
const parsed = new URL(trimmed);
const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase();
} catch {
return trimmed.toLowerCase();
}
}
function resolveGatewayCompatibilityKey(state: UsageState): string {
return normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl);
}
function shouldSendLegacyDateInterpretation(state: UsageState): boolean {
return !getLegacyUsageDateParamsCache().has(resolveGatewayCompatibilityKey(state));
}
function rememberLegacyDateInterpretation(state: UsageState) {
const cache = getLegacyUsageDateParamsCache();
cache.add(resolveGatewayCompatibilityKey(state));
persistLegacyUsageDateParamsCache(cache);
}
function isLegacyDateInterpretationUnsupportedError(err: unknown): boolean {
const message = toErrorMessage(err);
return (
LEGACY_USAGE_DATE_PARAMS_INVALID_RE.test(message) &&
(LEGACY_USAGE_DATE_PARAMS_MODE_RE.test(message) ||
LEGACY_USAGE_DATE_PARAMS_OFFSET_RE.test(message))
);
}
const formatUtcOffset = (timezoneOffsetMinutes: number): string => {
// `Date#getTimezoneOffset()` is minutes to add to local time to reach UTC.
// Convert to UTC±H[:MM] where positive means east of UTC.
const offsetFromUtcMinutes = -timezoneOffsetMinutes;
const sign = offsetFromUtcMinutes >= 0 ? "+" : "-";
const absMinutes = Math.abs(offsetFromUtcMinutes);
const hours = Math.floor(absMinutes / 60);
const minutes = absMinutes % 60;
return minutes === 0
? `UTC${sign}${hours}`
: `UTC${sign}${hours}:${minutes.toString().padStart(2, "0")}`;
};
const buildDateInterpretationParams = (
timeZone: "local" | "utc",
includeDateInterpretation: boolean,
): UsageDateInterpretationParams | undefined => {
if (!includeDateInterpretation) {
return undefined;
}
if (timeZone === "utc") {
return { mode: "utc" };
}
return {
mode: "specific",
utcOffset: formatUtcOffset(new Date().getTimezoneOffset()),
};
};
function toErrorMessage(err: unknown): string {
if (typeof err === "string") {
return err;
}
if (err instanceof Error && typeof err.message === "string" && err.message.trim()) {
return err.message;
}
if (err && typeof err === "object") {
try {
const serialized = JSON.stringify(err);
if (serialized) {
return serialized;
}
} catch {
// ignore
}
}
return "request failed";
}
export async function loadUsage(
state: UsageState,
overrides?: {
@@ -28,7 +189,9 @@ export async function loadUsage(
endDate?: string;
},
) {
if (!state.client || !state.connected) {
// Capture client for TS18047 work around on it being possibly null
const client = state.client;
if (!client || !state.connected) {
return;
}
if (state.usageLoading) {
@@ -39,31 +202,71 @@ export async function loadUsage(
try {
const startDate = overrides?.startDate ?? state.usageStartDate;
const endDate = overrides?.endDate ?? state.usageEndDate;
const runUsageRequests = async (includeDateInterpretation: boolean) => {
const dateInterpretation = buildDateInterpretationParams(
state.usageTimeZone,
includeDateInterpretation,
);
return await Promise.all([
client.request("sessions.usage", {
startDate,
endDate,
...dateInterpretation,
limit: 1000, // Cap at 1000 sessions
includeContextWeight: true,
}),
client.request("usage.cost", {
startDate,
endDate,
...dateInterpretation,
}),
]);
};
// Load both endpoints in parallel
const [sessionsRes, costRes] = await Promise.all([
state.client.request("sessions.usage", {
startDate,
endDate,
limit: 1000, // Cap at 1000 sessions
includeContextWeight: true,
}),
state.client.request("usage.cost", { startDate, endDate }),
]);
const applyUsageResults = (sessionsRes: unknown, costRes: unknown) => {
if (sessionsRes) {
state.usageResult = sessionsRes as SessionsUsageResult;
}
if (costRes) {
state.usageCostSummary = costRes as CostUsageSummary;
}
};
if (sessionsRes) {
state.usageResult = sessionsRes as SessionsUsageResult;
}
if (costRes) {
state.usageCostSummary = costRes as CostUsageSummary;
const includeDateInterpretation = shouldSendLegacyDateInterpretation(state);
try {
const [sessionsRes, costRes] = await runUsageRequests(includeDateInterpretation);
applyUsageResults(sessionsRes, costRes);
} catch (err) {
if (includeDateInterpretation && isLegacyDateInterpretationUnsupportedError(err)) {
// Older gateways reject `mode`/`utcOffset` in `sessions.usage`.
// Remember this per gateway and retry once without those fields.
rememberLegacyDateInterpretation(state);
const [sessionsRes, costRes] = await runUsageRequests(false);
applyUsageResults(sessionsRes, costRes);
} else {
throw err;
}
}
} catch (err) {
state.usageError = String(err);
state.usageError = toErrorMessage(err);
} finally {
state.usageLoading = false;
}
}
export const __test = {
formatUtcOffset,
buildDateInterpretationParams,
toErrorMessage,
isLegacyDateInterpretationUnsupportedError,
normalizeGatewayCompatibilityKey,
shouldSendLegacyDateInterpretation,
rememberLegacyDateInterpretation,
resetLegacyUsageDateParamsCache: () => {
legacyUsageDateParamsCache = null;
},
};
export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) {
if (!state.client || !state.connected) {
return;