fix(FN-927): fix usage reset time parsing and display fallback

- Fix backend regex in usage.ts to correctly parse CLI date-based reset text format
- Add frontend fallback in UsageIndicator to display relative reset time when absolute date is unavailable
- Add tests for backend regex parsing (usage.test.ts)
- Add tests for frontend UsageIndicator component (UsageIndicator.test.tsx)
This commit is contained in:
gsxdsm
2026-04-04 13:22:55 -07:00
parent faf2a7b43b
commit 7a71108397
4 changed files with 235 additions and 3 deletions

View File

@@ -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(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
// 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(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
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(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
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(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
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(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
// 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(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
// Session window should NOT get the weekly fallback treatment
expect(document.querySelector(".usage-window-reset")).not.toBeInTheDocument();
});
});

View File

@@ -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. */}
<span className="usage-window-reset-group">
{window.resetText && (
<span className="usage-window-reset">{window.resetText}</span>
{displayResetText && (
<span className="usage-window-reset">{displayResetText}</span>
)}
{/* Absolute reset timestamp: shown for session windows and non-Claude providers.
Claude weekly windows intentionally suppress this — the relative "resets in Xd"

View File

@@ -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();

View File

@@ -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<string, number> = {