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:
gsxdsm
2026-04-05 21:59:42 -07:00
parent b670d8b3e3
commit 6b7e57720b
3 changed files with 196 additions and 10 deletions

View File

@@ -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$/);
});
});

View File

@@ -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",