feat(FN-767): add Claude session reset time to usage modal
- Preserve Claude reset timestamps in the usage data model (usage.ts) - Display session reset time in the UsageIndicator dashboard component - Add component comments explaining reset-time rendering logic - Add styles for the reset time display in the usage modal - Add tests for usage data model and UsageIndicator component
This commit is contained in:
@@ -370,6 +370,7 @@ export interface UsageWindow {
|
|||||||
percentLeft: number; // 0-100
|
percentLeft: number; // 0-100
|
||||||
resetText: string | null; // e.g., "resets in 2h"
|
resetText: string | null; // e.g., "resets in 2h"
|
||||||
resetMs?: number; // ms until reset
|
resetMs?: number; // ms until reset
|
||||||
|
resetAt?: string; // ISO 8601 timestamp of when the window resets (machine-readable)
|
||||||
windowDurationMs?: number; // total window length
|
windowDurationMs?: number; // total window length
|
||||||
pace?: UsagePace; // pace indicator for weekly windows
|
pace?: UsagePace; // pace indicator for weekly windows
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1184,4 +1184,101 @@ describe("UsageIndicator", () => {
|
|||||||
expect(progressBar).toBeInTheDocument();
|
expect(progressBar).toBeInTheDocument();
|
||||||
expect(progressBar.style.width).toBe("46%"); // 45.678 rounds to 46
|
expect(progressBar.style.width).toBe("46%"); // 45.678 rounds to 46
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// resetAt timestamp display tests
|
||||||
|
it("shows absolute reset time when resetAt is provided", () => {
|
||||||
|
const resetAt = new Date(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
mockUseUsageData.mockReturnValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "Claude",
|
||||||
|
icon: "🟠",
|
||||||
|
status: "ok",
|
||||||
|
windows: [
|
||||||
|
{
|
||||||
|
label: "Session (5h)",
|
||||||
|
percentUsed: 45,
|
||||||
|
percentLeft: 55,
|
||||||
|
resetText: "resets in 2h",
|
||||||
|
resetMs: 7200000,
|
||||||
|
resetAt: resetAt.toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
refresh: mockRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
// Should show both relative text and absolute time
|
||||||
|
expect(screen.getByText("resets in 2h")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".usage-window-reset-at")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show absolute reset time when resetAt is absent", () => {
|
||||||
|
mockUseUsageData.mockReturnValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "Claude",
|
||||||
|
icon: "🟠",
|
||||||
|
status: "ok",
|
||||||
|
windows: [
|
||||||
|
{
|
||||||
|
label: "Session (5h)",
|
||||||
|
percentUsed: 45,
|
||||||
|
percentLeft: 55,
|
||||||
|
resetText: "resets in 2h",
|
||||||
|
resetMs: 7200000,
|
||||||
|
// no resetAt
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
refresh: mockRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("resets in 2h")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".usage-window-reset-at")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows reset time for weekly window when resetAt is provided", () => {
|
||||||
|
const resetAt = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
|
||||||
|
mockUseUsageData.mockReturnValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "Claude",
|
||||||
|
icon: "🟠",
|
||||||
|
status: "ok",
|
||||||
|
windows: [
|
||||||
|
{
|
||||||
|
label: "Weekly",
|
||||||
|
percentUsed: 30,
|
||||||
|
percentLeft: 70,
|
||||||
|
resetText: "resets in 3d",
|
||||||
|
resetMs: 259200000,
|
||||||
|
resetAt: resetAt.toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
refresh: mockRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("resets in 3d")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".usage-window-reset-at")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,48 @@ interface UsageIndicatorProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO 8601 timestamp into a user-friendly absolute time string.
|
||||||
|
* Shows time like "2:30 PM" for today, "Tue 2:30 PM" for this week,
|
||||||
|
* or "Jan 15, 2:30 PM" for later dates.
|
||||||
|
*
|
||||||
|
* Used by UsageWindowRow to display the absolute reset time next to the
|
||||||
|
* relative "resets in X" text when the backend provides a canonical resetAt
|
||||||
|
* timestamp. Currently populated only for Claude session/windows where the
|
||||||
|
* reset timestamp is available from the API or CLI fallback parser.
|
||||||
|
*/
|
||||||
|
function formatResetAt(isoTimestamp: string): string {
|
||||||
|
const date = new Date(isoTimestamp);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const timeStr = date.toLocaleTimeString(undefined, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
if (isToday) {
|
||||||
|
return timeStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if within the next 7 days — show short weekday
|
||||||
|
const daysUntil = Math.round(
|
||||||
|
(date.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||||
|
);
|
||||||
|
if (daysUntil > 0 && daysUntil <= 6) {
|
||||||
|
const weekday = date.toLocaleDateString(undefined, { weekday: "short" });
|
||||||
|
return `${weekday} ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beyond a week — show full date
|
||||||
|
const dateStr = date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
return `${dateStr}, ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get color class for usage percentage
|
* Get color class for usage percentage
|
||||||
* - >90%: high (red/error color)
|
* - >90%: high (red/error color)
|
||||||
@@ -83,9 +125,21 @@ function UsageWindowRow({ window, viewMode }: UsageWindowRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="usage-window-footer">
|
<div className="usage-window-footer">
|
||||||
<span className="usage-window-left">{footerText}</span>
|
<span className="usage-window-left">{footerText}</span>
|
||||||
{window.resetText && (
|
{/* Reset group: shows relative text ("resets in 2h") and, when available,
|
||||||
<span className="usage-window-reset">{window.resetText}</span>
|
the absolute reset time derived from the canonical resetAt timestamp.
|
||||||
)}
|
The absolute time is populated by the backend for Claude session/windows
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{window.resetAt && (
|
||||||
|
<span className="usage-window-reset-at">
|
||||||
|
{formatResetAt(window.resetAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{shouldShowPace && (
|
{shouldShowPace && (
|
||||||
<div className="usage-pace-row" data-testid="pace-row">
|
<div className="usage-pace-row" data-testid="pace-row">
|
||||||
|
|||||||
@@ -10322,6 +10322,19 @@ html .column.drag-over * {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usage-window-reset-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-window-reset-at {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pace marker and text */
|
/* Pace marker and text */
|
||||||
.usage-progress-wrapper {
|
.usage-progress-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -734,6 +734,66 @@ describe("usage", () => {
|
|||||||
_resetSleepFn();
|
_resetSleepFn();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("populates resetAt timestamp for session window from API response", async () => {
|
||||||
|
const resetTime = new Date(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
setupClaudeMocks({
|
||||||
|
credFileContent: {
|
||||||
|
accessToken: "test-token",
|
||||||
|
scopes: ["user:profile"],
|
||||||
|
subscriptionType: "pro",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setupClaudeApiResponse({
|
||||||
|
five_hour: {
|
||||||
|
utilization: 45.5,
|
||||||
|
resets_at: resetTime.toISOString(),
|
||||||
|
},
|
||||||
|
seven_day: {
|
||||||
|
utilization: 23.0,
|
||||||
|
resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const claude = providers.find((p) => p.name === "Claude")!;
|
||||||
|
|
||||||
|
expect(claude.status).toBe("ok");
|
||||||
|
expect(claude.windows).toHaveLength(2);
|
||||||
|
|
||||||
|
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
|
||||||
|
expect(sessionWindow.resetAt).toBe(resetTime.toISOString());
|
||||||
|
expect(sessionWindow.resetText).toContain("resets in");
|
||||||
|
|
||||||
|
const weeklyWindow = claude.windows.find((w) => w.label === "Weekly")!;
|
||||||
|
expect(weeklyWindow.resetAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits resetAt when API response has no resets_at field", async () => {
|
||||||
|
setupClaudeMocks({
|
||||||
|
credFileContent: {
|
||||||
|
accessToken: "test-token",
|
||||||
|
scopes: ["user:profile"],
|
||||||
|
subscriptionType: "pro",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setupClaudeApiResponse({
|
||||||
|
five_hour: {
|
||||||
|
utilization: 30.0,
|
||||||
|
// no resets_at field
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const claude = providers.find((p) => p.name === "Claude")!;
|
||||||
|
|
||||||
|
expect(claude.status).toBe("ok");
|
||||||
|
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
|
||||||
|
expect(sessionWindow.resetAt).toBeUndefined();
|
||||||
|
expect(sessionWindow.resetText).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("handles empty JSON object from API gracefully", async () => {
|
it("handles empty JSON object from API gracefully", async () => {
|
||||||
setupClaudeMocks({
|
setupClaudeMocks({
|
||||||
credFileContent: {
|
credFileContent: {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface UsageWindow {
|
|||||||
percentLeft: number; // 0-100
|
percentLeft: number; // 0-100
|
||||||
resetText: string | null; // e.g., "resets in 2h"
|
resetText: string | null; // e.g., "resets in 2h"
|
||||||
resetMs?: number; // ms until reset
|
resetMs?: number; // ms until reset
|
||||||
|
resetAt?: string; // ISO 8601 timestamp of when the window resets (machine-readable)
|
||||||
windowDurationMs?: number; // total window length
|
windowDurationMs?: number; // total window length
|
||||||
pace?: UsagePace; // pace indicator for weekly windows
|
pace?: UsagePace; // pace indicator for weekly windows
|
||||||
}
|
}
|
||||||
@@ -651,6 +652,7 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
|||||||
if (iso) {
|
if (iso) {
|
||||||
const msLeft = new Date(iso).getTime() - Date.now();
|
const msLeft = new Date(iso).getTime() - Date.now();
|
||||||
window.resetMs = msLeft > 0 ? msLeft : 0;
|
window.resetMs = msLeft > 0 ? msLeft : 0;
|
||||||
|
window.resetAt = iso;
|
||||||
if (!window.resetText) {
|
if (!window.resetText) {
|
||||||
window.resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
window.resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||||
}
|
}
|
||||||
@@ -833,9 +835,9 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
|||||||
let resetText: string | null = null;
|
let resetText: string | null = null;
|
||||||
let resetMs: number | undefined;
|
let resetMs: number | undefined;
|
||||||
|
|
||||||
const resetAt = w.resets_at || w.reset_at || w.resetAt;
|
const resetAtValue = w.resets_at || w.reset_at || w.resetAt;
|
||||||
if (resetAt) {
|
if (resetAtValue) {
|
||||||
const msLeft = new Date(resetAt).getTime() - Date.now();
|
const msLeft = new Date(resetAtValue).getTime() - Date.now();
|
||||||
resetMs = msLeft > 0 ? msLeft : 0;
|
resetMs = msLeft > 0 ? msLeft : 0;
|
||||||
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||||
}
|
}
|
||||||
@@ -847,6 +849,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
|||||||
resetText,
|
resetText,
|
||||||
windowDurationMs,
|
windowDurationMs,
|
||||||
resetMs,
|
resetMs,
|
||||||
|
resetAt: resetAtValue || undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user