fix(FN-1026): normalize formatResetAt to use calendar-day boundaries
- Fix formatResetAt to compute day difference from calendar midnight boundaries instead of raw millisecond division - Prevents off-by-one day counts that caused inconsistent formatting near 7-day boundary - Add comprehensive unit tests for formatResetAt boundary conditions (7-day edge, today, beyond 7 days) - Fix pre-existing scheduler test assertion to use expect.objectContaining for worktree/baseBranch fields
This commit is contained in:
@@ -1735,4 +1735,177 @@ describe("UsageIndicator", () => {
|
||||
// All windows now get fallback text generation when resetText is null but resetAt exists
|
||||
expect(document.querySelector(".usage-window-reset")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// formatResetAt boundary regression tests
|
||||
it("resetAt exactly 7 calendar days away shows weekday format, not month/day", () => {
|
||||
// Construct a date exactly 7 calendar days from now at an arbitrary time
|
||||
const now = new Date();
|
||||
const resetAt = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() + 7,
|
||||
14, 30, 0, 0 // 2:30 PM seven days from now
|
||||
);
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "Codex",
|
||||
icon: "🟢",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Session",
|
||||
percentUsed: 20,
|
||||
percentLeft: 80,
|
||||
resetText: "resets in 7d",
|
||||
resetMs: 7 * 24 * 60 * 60 * 1000,
|
||||
resetAt: resetAt.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const resetAtEl = document.querySelector(".usage-window-reset-at");
|
||||
expect(resetAtEl).toBeInTheDocument();
|
||||
// Should show weekday format like "Mon 2:30 PM", NOT "Apr 13, 2:30 PM"
|
||||
expect(resetAtEl?.textContent).toMatch(/^[A-Z][a-z]{2} \d{1,2}:\d{2} [AP]M$/);
|
||||
// Should NOT contain a comma (which would indicate month/day format)
|
||||
expect(resetAtEl?.textContent).not.toMatch(/,/);
|
||||
});
|
||||
|
||||
it("resetAt just under 7 days shows weekday format regardless of time-of-day", () => {
|
||||
// 6 days + 23 hours — time-of-day rounding could previously cause this
|
||||
// to flip between weekday and month/day format
|
||||
const now = new Date();
|
||||
const resetAt = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() + 6,
|
||||
now.getHours() + 23,
|
||||
now.getMinutes(),
|
||||
now.getSeconds()
|
||||
);
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "Codex",
|
||||
icon: "🟢",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Session",
|
||||
percentUsed: 30,
|
||||
percentLeft: 70,
|
||||
resetText: "resets in 6d",
|
||||
resetMs: 6 * 24 * 60 * 60 * 1000 + 23 * 60 * 60 * 1000,
|
||||
resetAt: resetAt.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const resetAtEl = document.querySelector(".usage-window-reset-at");
|
||||
expect(resetAtEl).toBeInTheDocument();
|
||||
// Should show weekday format like "Sat 3:45 PM"
|
||||
expect(resetAtEl?.textContent).toMatch(/^[A-Z][a-z]{2} \d{1,2}:\d{2} [AP]M$/);
|
||||
});
|
||||
|
||||
it("resetAt 8 calendar days away shows month/day format", () => {
|
||||
const now = new Date();
|
||||
const resetAt = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() + 8,
|
||||
10, 0, 0, 0
|
||||
);
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "Codex",
|
||||
icon: "🟢",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 10,
|
||||
percentLeft: 90,
|
||||
resetText: "resets in 8d",
|
||||
resetMs: 8 * 24 * 60 * 60 * 1000,
|
||||
resetAt: resetAt.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const resetAtEl = document.querySelector(".usage-window-reset-at");
|
||||
expect(resetAtEl).toBeInTheDocument();
|
||||
// Should show month/day format like "Apr 14, 10:00 AM"
|
||||
expect(resetAtEl?.textContent).toMatch(/^[A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2} [AP]M$/);
|
||||
});
|
||||
|
||||
it("resetAt tomorrow consistently shows weekday format at any hour", () => {
|
||||
// Set the reset time to 1:00 AM tomorrow — previously edge-case for rounding
|
||||
const now = new Date();
|
||||
const resetAt = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() + 1,
|
||||
1, 0, 0, 0 // 1:00 AM tomorrow
|
||||
);
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "Codex",
|
||||
icon: "🟢",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Session",
|
||||
percentUsed: 50,
|
||||
percentLeft: 50,
|
||||
resetText: "resets in 1d",
|
||||
resetMs: 24 * 60 * 60 * 1000,
|
||||
resetAt: resetAt.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const resetAtEl = document.querySelector(".usage-window-reset-at");
|
||||
expect(resetAtEl).toBeInTheDocument();
|
||||
// Tomorrow should always be weekday format, never month/day
|
||||
expect(resetAtEl?.textContent).toMatch(/^[A-Z][a-z]{2} \d{1,2}:\d{2} [AP]M$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,20 @@ interface UsageIndicatorProps {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Formatting tiers (applied consistently for all providers):
|
||||
* - Today: "2:30 PM"
|
||||
* - Next 7 days: "Tue 2:30 PM" (weekday + time)
|
||||
* - Beyond 7 days: "Jan 15, 2:30 PM"
|
||||
*
|
||||
* Day difference is computed from calendar midnight boundaries rather than
|
||||
* raw millisecond division to avoid time-of-day rounding artifacts that could
|
||||
* cause inconsistent formatting (e.g., showing "Apr 6" instead of "Sun 2:30 PM"
|
||||
* for a reset that is just under 7 days away).
|
||||
*
|
||||
* 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.
|
||||
* timestamp.
|
||||
*/
|
||||
function formatResetAt(isoTimestamp: string): string {
|
||||
const date = new Date(isoTimestamp);
|
||||
@@ -34,16 +41,22 @@ function formatResetAt(isoTimestamp: string): string {
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
// Check if within the next 7 days — show short weekday
|
||||
const daysUntil = Math.round(
|
||||
(date.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||
// Compute calendar-day distance using midnight boundaries.
|
||||
// This avoids floating-point rounding from raw millisecond division
|
||||
// that can cause off-by-one day counts depending on time-of-day.
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startOfTarget = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const calendarDaysUntil = Math.round(
|
||||
(startOfTarget.getTime() - startOfToday.getTime()) / (24 * 60 * 60 * 1000)
|
||||
);
|
||||
if (daysUntil > 0 && daysUntil <= 6) {
|
||||
|
||||
// Within the next 7 calendar days — show short weekday + time
|
||||
if (calendarDaysUntil >= 1 && calendarDaysUntil <= 7) {
|
||||
const weekday = date.toLocaleDateString(undefined, { weekday: "short" });
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
|
||||
// Beyond a week — show full date
|
||||
// Beyond 7 days — show full date
|
||||
const dateStr = date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
||||
Reference in New Issue
Block a user