From a5ac4dae56cad06bed55bdb5ebbd5e6e79d15b21 Mon Sep 17 00:00:00 2001 From: Jacob Kim Date: Thu, 9 Jul 2026 22:42:57 +0900 Subject: [PATCH] fix(status-bar): show claude usage reset time --- .../rate-limits/claude-usage-fetcher.ts | 29 +++++++++-- .../layout/StatusBarUsageSegment.tsx | 2 +- tests/claude-usage-fetcher.test.ts | 51 +++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 tests/claude-usage-fetcher.test.ts diff --git a/electron/providers/rate-limits/claude-usage-fetcher.ts b/electron/providers/rate-limits/claude-usage-fetcher.ts index 62f6550..00aff1f 100644 --- a/electron/providers/rate-limits/claude-usage-fetcher.ts +++ b/electron/providers/rate-limits/claude-usage-fetcher.ts @@ -16,7 +16,7 @@ const REQUEST_TIMEOUT_MS = 10_000; * statusline-style `used_percentage` (0..100), each paired with an * epoch-second `resets_at` — see the usage-bar plan's research notes. */ -function normalizeWindow(raw: unknown): ClaudeUsageWindow | null { +export function normalizeWindow(raw: unknown): ClaudeUsageWindow | null { if (!raw || typeof raw !== "object") { return null; } @@ -31,11 +31,34 @@ function normalizeWindow(raw: unknown): ClaudeUsageWindow | null { if (usedPercent === null) { return null; } - const resetsAt = - typeof window.resets_at === "number" ? window.resets_at : null; + const resetsAt = normalizeResetsAt(window.resets_at); return { usedPercent, resetsAt }; } +/** + * `resets_at` has been observed as an epoch-second number, but the OAuth + * endpoint's response shape isn't documented and other Anthropic APIs use + * ISO-8601 strings for reset timestamps — accept both rather than silently + * dropping the value (which showed up as a permanent "unknown" reset time + * in the UI even though the server had sent a real value). + */ +export function normalizeResetsAt(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsedMs = Date.parse(value); + if (!Number.isNaN(parsedMs)) { + return Math.floor(parsedMs / 1000); + } + const parsedNumber = Number(value); + if (Number.isFinite(parsedNumber)) { + return parsedNumber; + } + } + return null; +} + /** * Returns `null` (rather than an "unavailable" snapshot) for anything the * CLI fallback might still recover from: missing credentials, network diff --git a/src/components/layout/StatusBarUsageSegment.tsx b/src/components/layout/StatusBarUsageSegment.tsx index 3e9ec08..4e8faca 100644 --- a/src/components/layout/StatusBarUsageSegment.tsx +++ b/src/components/layout/StatusBarUsageSegment.tsx @@ -24,7 +24,7 @@ function dotColorClass(usedPercent: number): string { } function formatResetsIn(resetsAt: number | null): string { - if (!resetsAt) { + if (resetsAt === null || Number.isNaN(resetsAt)) { return "unknown"; } const deltaMs = resetsAt * 1000 - Date.now(); diff --git a/tests/claude-usage-fetcher.test.ts b/tests/claude-usage-fetcher.test.ts new file mode 100644 index 0000000..88530d4 --- /dev/null +++ b/tests/claude-usage-fetcher.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { + normalizeResetsAt, + normalizeWindow, +} from "../electron/providers/rate-limits/claude-usage-fetcher"; + +describe("normalizeResetsAt", () => { + test("accepts an epoch-seconds number", () => { + expect(normalizeResetsAt(1_785_542_400)).toBe(1_785_542_400); + }); + + test("accepts an ISO-8601 string and converts to epoch seconds", () => { + const iso = "2026-07-09T18:00:00.000Z"; + expect(normalizeResetsAt(iso)).toBe(Math.floor(Date.parse(iso) / 1000)); + }); + + test("accepts a numeric string", () => { + expect(normalizeResetsAt("1785542400")).toBe(1_785_542_400); + }); + + test("returns null for missing/unparseable values", () => { + expect(normalizeResetsAt(undefined)).toBeNull(); + expect(normalizeResetsAt(null)).toBeNull(); + expect(normalizeResetsAt("not-a-date")).toBeNull(); + }); +}); + +describe("normalizeWindow", () => { + test("keeps a real resets_at instead of dropping it when it's an ISO string", () => { + const window = normalizeWindow({ + used_percentage: 42, + resets_at: "2026-07-09T18:00:00.000Z", + }); + expect(window).not.toBeNull(); + expect(window?.usedPercent).toBe(42); + expect(window?.resetsAt).not.toBeNull(); + }); + + test("still supports the numeric epoch-seconds shape", () => { + const window = normalizeWindow({ + utilization: 0.5, + resets_at: 1_785_542_400, + }); + expect(window?.usedPercent).toBe(50); + expect(window?.resetsAt).toBe(1_785_542_400); + }); + + test("returns null when there's no usage percentage at all", () => { + expect(normalizeWindow({ resets_at: 123 })).toBeNull(); + }); +});