diff --git a/src/tui.tsx b/src/tui.tsx index 7870ac5..32357e9 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -13,13 +13,32 @@ function percent(value: number) { return value < 0.1 && value > 0 ? "<0.1" : value.toFixed(value < 10 ? 1 : 0); } -export function formatQuotaWindow( - window: QuotaWindow & { remaining: number; sessionConsumed: number }, -) { +function resetTiming(resetsAt: number, now: number) { + if (!Number.isFinite(resetsAt) || resetsAt <= 0 || !Number.isFinite(now)) + return "reset unavailable"; + const remaining = resetsAt - now; + if (remaining <= 0) return "reset due"; + if (remaining < 60_000) return "resets in <1m"; + + const minutes = Math.floor(remaining / 60_000); + const days = Math.floor(minutes / 1_440); + if (days > 0) { + const hours = Math.floor((minutes % 1_440) / 60); + return `resets in ${days}d${hours > 0 ? ` ${hours}h` : ""}`; + } + const hours = Math.floor(minutes / 60); + if (hours > 0) { + const rest = minutes % 60; + return `resets in ${hours}h${rest > 0 ? ` ${rest}m` : ""}`; + } + return `resets in ${minutes}m`; +} + +export function formatQuotaWindow(window: QuotaWindow & { remaining: number }, now = Date.now()) { const label = window.label ? `${window.label} ${duration(window.seconds)}` : duration(window.seconds); - return `${label}: ${percent(window.remaining)}% left · ~${percent(window.sessionConsumed)}pp this session`; + return `${label}: ${percent(window.remaining)}% left · ${resetTiming(window.resetsAt, now)}`; } export async function loadQuotaStatus(load: () => Promise) { diff --git a/tests/quota.test.ts b/tests/quota.test.ts index 3714d2e..6166970 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -415,18 +415,58 @@ test("polls, carries consumption across quota resets, tolerates errors, and clea expect(result.calls()).toBe(calls); }); -test("sidebar text places estimated percentage-point use beside latest remaining quota", () => { +test("sidebar text places reset timing beside latest remaining quota", () => { expect( - formatQuotaWindow({ - seconds: 604_800, - id: "codex:secondary", - label: null, - used: 25, - remaining: 75, - sessionConsumed: 2.25, - resetsAt: 0, - }), - ).toBe("7d: 75% left · ~2.3pp this session"); + formatQuotaWindow( + { + seconds: 604_800, + id: "codex:secondary", + label: null, + used: 25, + remaining: 75, + resetsAt: 100_000 + 2 * 86_400_000 + 5 * 3_600_000, + }, + 100_000, + ), + ).toBe("7d: 75% left · resets in 2d 5h"); +}); + +test("sidebar reset timing uses compact future units and handles boundaries", () => { + const now = 1_000_000; + const format = (resetIn: number) => + formatQuotaWindow( + { + seconds: 18_000, + id: "codex:primary", + label: null, + used: 25, + remaining: 75, + resetsAt: now + resetIn, + }, + now, + ); + + expect(format(2 * 3_600_000 + 15 * 60_000)).toBe("5h: 75% left · resets in 2h 15m"); + expect(format(3_600_000)).toBe("5h: 75% left · resets in 1h"); + expect(format(15 * 60_000)).toBe("5h: 75% left · resets in 15m"); + expect(format(60_000)).toBe("5h: 75% left · resets in 1m"); + expect(format(59_999)).toBe("5h: 75% left · resets in <1m"); +}); + +test("sidebar reset timing distinguishes stale and unavailable timestamps", () => { + const window = { + seconds: 18_000, + id: "codex:primary", + label: "GPT-5.6 Luna", + used: 99.95, + remaining: 0.05, + }; + expect(formatQuotaWindow({ ...window, resetsAt: 999 }, 1_000)).toBe( + "GPT-5.6 Luna 5h: <0.1% left · reset due", + ); + expect(formatQuotaWindow({ ...window, resetsAt: Number.NaN }, 1_000)).toBe( + "GPT-5.6 Luna 5h: <0.1% left · reset unavailable", + ); }); test("inactive roots freeze, resume from a cached baseline on failure, and deactivate by family", async () => { diff --git a/tests/tui.test.ts b/tests/tui.test.ts index 73492c8..54ee7b6 100644 --- a/tests/tui.test.ts +++ b/tests/tui.test.ts @@ -84,7 +84,7 @@ test("built quota slot stays reactive across pending, empty, failure, update, an pending.resolve(status("plus", 75)); await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (plus)")); - expect(setup.captureCharFrame()).toContain("5h: 75% left · ~1.5pp this session"); + expect(setup.captureCharFrame()).toContain("5h: 75% left · reset due"); responses.push(async () => null); notifyUpdated();