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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user