diff --git a/packages/dashboard/app/components/UsageIndicator.test.tsx b/packages/dashboard/app/components/UsageIndicator.test.tsx index 2d8e4effa..62483c1db 100644 --- a/packages/dashboard/app/components/UsageIndicator.test.tsx +++ b/packages/dashboard/app/components/UsageIndicator.test.tsx @@ -1548,4 +1548,192 @@ describe("UsageIndicator", () => { // Non-Claude weekly windows are unaffected — should still show absolute time expect(document.querySelector(".usage-window-reset-at")).toBeInTheDocument(); }); + + // Claude weekly reset fallback tests + it("Claude weekly window generates fallback relative text when resetText is null but resetAt exists", () => { + const resetAt = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000 + 5 * 60 * 60 * 1000); // 3d 5h + mockUseUsageData.mockReturnValue({ + providers: [ + { + name: "Claude", + icon: "🟠", + status: "ok", + windows: [ + { + label: "Weekly", + percentUsed: 30, + percentLeft: 70, + resetText: null, // No resetText from backend + resetMs: 3 * 24 * 60 * 60 * 1000 + 5 * 60 * 60 * 1000, + resetAt: resetAt.toISOString(), + }, + ], + }, + ], + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + }); + + render(); + + // Should show fallback "resets in Xd Xh" text + expect(screen.getByText(/resets in \d+d \d+h/)).toBeInTheDocument(); + }); + + it("Claude weekly window fallback shows only days when no remainder hours", () => { + const resetAt = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); // exactly 3d + mockUseUsageData.mockReturnValue({ + providers: [ + { + name: "Claude", + icon: "🟠", + status: "ok", + windows: [ + { + label: "Weekly", + percentUsed: 30, + percentLeft: 70, + resetText: null, + resetMs: 3 * 24 * 60 * 60 * 1000, + resetAt: resetAt.toISOString(), + }, + ], + }, + ], + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + }); + + render(); + + expect(screen.getByText(/resets in \d+d/)).toBeInTheDocument(); + }); + + it("Claude weekly window fallback shows hours when less than 1 day remaining", () => { + const resetAt = new Date(Date.now() + 5 * 60 * 60 * 1000); // 5h + mockUseUsageData.mockReturnValue({ + providers: [ + { + name: "Claude", + icon: "🟠", + status: "ok", + windows: [ + { + label: "Weekly", + percentUsed: 80, + percentLeft: 20, + resetText: null, + resetMs: 5 * 60 * 60 * 1000, + resetAt: resetAt.toISOString(), + }, + ], + }, + ], + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + }); + + render(); + + expect(screen.getByText(/resets in \d+h/)).toBeInTheDocument(); + }); + + it("Claude weekly window fallback shows minutes when less than 1 hour remaining", () => { + const resetAt = new Date(Date.now() + 45 * 60 * 1000); // 45m + mockUseUsageData.mockReturnValue({ + providers: [ + { + name: "Claude", + icon: "🟠", + status: "ok", + windows: [ + { + label: "Weekly", + percentUsed: 95, + percentLeft: 5, + resetText: null, + resetMs: 45 * 60 * 1000, + resetAt: resetAt.toISOString(), + }, + ], + }, + ], + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + }); + + render(); + + expect(screen.getByText(/resets in \d+m/)).toBeInTheDocument(); + }); + + it("Claude weekly window does not show fallback when both resetText and resetAt are null", () => { + mockUseUsageData.mockReturnValue({ + providers: [ + { + name: "Claude", + icon: "🟠", + status: "ok", + windows: [ + { + label: "Weekly", + percentUsed: 50, + percentLeft: 50, + resetText: null, + // No resetAt either + }, + ], + }, + ], + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + }); + + render(); + + // No reset text should be shown + expect(document.querySelector(".usage-window-reset")).not.toBeInTheDocument(); + }); + + it("Claude session window does not generate fallback text (only weekly)", () => { + const resetAt = new Date(Date.now() + 3 * 60 * 60 * 1000); + mockUseUsageData.mockReturnValue({ + providers: [ + { + name: "Claude", + icon: "🟠", + status: "ok", + windows: [ + { + label: "Session (5h)", + percentUsed: 60, + percentLeft: 40, + resetText: null, // No resetText + resetMs: 3 * 60 * 60 * 1000, + resetAt: resetAt.toISOString(), + }, + ], + }, + ], + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + }); + + render(); + + // Session window should NOT get the weekly fallback treatment + expect(document.querySelector(".usage-window-reset")).not.toBeInTheDocument(); + }); }); diff --git a/packages/dashboard/app/components/UsageIndicator.tsx b/packages/dashboard/app/components/UsageIndicator.tsx index e8bf6fa9c..51032e661 100644 --- a/packages/dashboard/app/components/UsageIndicator.tsx +++ b/packages/dashboard/app/components/UsageIndicator.tsx @@ -98,6 +98,28 @@ function UsageWindowRow({ window, viewMode, providerName }: UsageWindowRowProps) const headerText = isRemainingMode ? `${Math.round(window.percentLeft)}% remaining` : `${Math.round(window.percentUsed)}% used`; const footerText = isRemainingMode ? `${Math.round(window.percentUsed)}% used` : `${Math.round(window.percentLeft)}% left`; + // If resetText is null but resetAt exists (and this is a Claude weekly window), + // generate relative text from resetAt as a fallback + let displayResetText = window.resetText; + if (!displayResetText && window.resetAt && isClaudeWeeklyWindow(providerName, window.label)) { + const msLeft = new Date(window.resetAt).getTime() - Date.now(); + if (msLeft > 0) { + const hours = Math.floor(msLeft / (60 * 60 * 1000)); + const days = Math.floor(hours / 24); + const remHours = hours % 24; + if (days > 0 && remHours > 0) { + displayResetText = `resets in ${days}d ${remHours}h`; + } else if (days > 0) { + displayResetText = `resets in ${days}d`; + } else if (hours > 0) { + displayResetText = `resets in ${hours}h`; + } else { + const mins = Math.floor(msLeft / (60 * 1000)); + displayResetText = `resets in ${mins}m`; + } + } + } + // Use pace from backend if available (for weekly windows) const pace = window.pace; const shouldShowPace = pace !== undefined; @@ -148,8 +170,8 @@ function UsageWindowRow({ window, viewMode, providerName }: UsageWindowRowProps) where the reset timestamp is known. Other providers will only show the relative text unless they also provide resetAt. */} - {window.resetText && ( - {window.resetText} + {displayResetText && ( + {displayResetText} )} {/* Absolute reset timestamp: shown for session windows and non-Claude providers. Claude weekly windows intentionally suppress this — the relative "resets in Xd" diff --git a/packages/dashboard/src/usage.test.ts b/packages/dashboard/src/usage.test.ts index 8b0c415f0..843c321b1 100644 --- a/packages/dashboard/src/usage.test.ts +++ b/packages/dashboard/src/usage.test.ts @@ -2549,6 +2549,26 @@ describe("usage", () => { expect(d.getHours()).toBe(15); }); + it("parses date format with 'at' immediately before time (no space after 'at')", () => { + // CLI output with cursor-forward sequences can produce "at3pm" instead of "at 3pm" + const result = _parseClaudeResetText("Resets Feb 19 at3pm"); + expect(result).toBeTruthy(); + const d = new Date(result!); + expect(d.getMonth()).toBe(1); // Feb + expect(d.getDate()).toBe(19); + expect(d.getHours()).toBe(15); + }); + + it("parses date format with 'at' and minutes (no space after 'at')", () => { + const result = _parseClaudeResetText("Resets Feb 19 at3:30pm"); + expect(result).toBeTruthy(); + const d = new Date(result!); + expect(d.getMonth()).toBe(1); // Feb + expect(d.getDate()).toBe(19); + expect(d.getHours()).toBe(15); + expect(d.getMinutes()).toBe(30); + }); + it("parses date format with comma", () => { const result = _parseClaudeResetText("Resets Jan 15, 3:30pm"); expect(result).toBeTruthy(); diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index 88f290d48..2b0f3adbe 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -438,8 +438,10 @@ export function _parseClaudeResetText(text: string): string | null { } // "Resets Feb 19 at 3pm" or "Resets Jan 15, 3:30pm" + // Note: \s+at\s* (not \s+at\s+) to handle CLI output where "at" may be + // immediately followed by the time with no space (e.g. "at3pm" from TUI cursor-forward). const dateMatch = text.match( - /(?:resets?\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i + /(?:resets?\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s*|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i ); if (dateMatch) { const months: Record = {