fix(dashboard): parse Codex reset_at as epoch ms or seconds
Codex returns weekly window reset_at in milliseconds in some cases, which was being multiplied by 1000 and producing nonsensical reset countdowns and pace calculations. Route through _parseResetTimestamp so both formats work, and add a regression test. Also tightens comments in useMobileKeyboard / ChatView around why visualViewport scroll events skip offsetTop updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/codex-weekly-reset-epoch-ms.md
Normal file
5
.changeset/codex-weekly-reset-epoch-ms.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Codex weekly usage pace calculation when the API returns `reset_at` as epoch milliseconds instead of seconds. The dashboard now parses both formats correctly so weekly reset countdowns and pace status reflect reality.
|
||||||
@@ -1106,9 +1106,10 @@
|
|||||||
.chat-thread--keyboard-active {
|
.chat-thread--keyboard-active {
|
||||||
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
|
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
|
||||||
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
|
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
|
||||||
/* useMobileKeyboard pins --vv-offset-top across pan-time scroll
|
/* Re-anchored: useMobileKeyboard now only updates --vv-offset-top
|
||||||
events (only resize/focus update it), so this transform anchors
|
on resize/focus transitions (not on every visualViewport scroll
|
||||||
the thread on initial focus without juddering during swipes. */
|
during a pan), so the transform tracks the keyboard open/close
|
||||||
|
without jittering during a swipe. */
|
||||||
transform: translateY(var(--vv-offset-top, 0px));
|
transform: translateY(var(--vv-offset-top, 0px));
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -540,7 +540,6 @@ export function ListView({
|
|||||||
return sortDirection === "asc" ? comparison : -comparison;
|
return sortDirection === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, selectedColumn]);
|
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, selectedColumn]);
|
||||||
|
|
||||||
|
|||||||
@@ -133,8 +133,9 @@ export function useMobileKeyboard(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full update for resize + focus transitions (real keyboard
|
// Full update — used on resize and focus transitions. These are the
|
||||||
// open/close events).
|
// events that signal an actual keyboard open/close, so we want to
|
||||||
|
// re-snapshot offsetTop/height/overlap.
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const metrics = getKeyboardMetrics();
|
const metrics = getKeyboardMetrics();
|
||||||
setKeyboardOverlap(metrics.overlap);
|
setKeyboardOverlap(metrics.overlap);
|
||||||
@@ -143,13 +144,13 @@ export function useMobileKeyboard(
|
|||||||
setKeyboardOpen(metrics.open);
|
setKeyboardOpen(metrics.open);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll-only update: visualViewport.scroll fires at 60fps during
|
// Scroll-only update — fires on every visualViewport pan (60fps on
|
||||||
// an iOS pan with the keyboard up. Routing offsetTop through React
|
// iOS during a swipe with the keyboard up). Updating offsetTop on
|
||||||
// state on every event amplifies the pan into a visible judder via
|
// each event amplifies jitter into the .chat-thread transform that
|
||||||
// the .chat-thread translateY(--vv-offset-top) transform. We skip
|
// tracks --vv-offset-top, visibly judders the thread, and can shift
|
||||||
// offsetTop here; it stays pinned to whatever resize/focus last
|
// it hundreds of px. We deliberately skip offsetTop here and only
|
||||||
// captured. Other metrics still update so a true viewport shrink
|
// update height/keyboardOpen if those changed; offsetTop stays
|
||||||
// is reflected.
|
// pinned to whatever resize/focus last set it.
|
||||||
const updateScrollOnly = () => {
|
const updateScrollOnly = () => {
|
||||||
const metrics = getKeyboardMetrics();
|
const metrics = getKeyboardMetrics();
|
||||||
setKeyboardOverlap(metrics.overlap);
|
setKeyboardOverlap(metrics.overlap);
|
||||||
|
|||||||
@@ -1935,6 +1935,56 @@ describe("usage", () => {
|
|||||||
const expectedMs = Date.now() + 7200 * 1000;
|
const expectedMs = Date.now() + 7200 * 1000;
|
||||||
expect(Math.abs(resetAtDate.getTime() - expectedMs)).toBeLessThan(1000);
|
expect(Math.abs(resetAtDate.getTime() - expectedMs)).toBeLessThan(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calculates weekly pace correctly when Codex reset_at is epoch milliseconds", async () => {
|
||||||
|
const fiveDaysMs = 5 * 24 * 60 * 60 * 1000;
|
||||||
|
const weeklyResetAtMs = Date.now() + fiveDaysMs;
|
||||||
|
const mockResponse = {
|
||||||
|
rate_limit: {
|
||||||
|
secondary_window: {
|
||||||
|
used_percent: 40,
|
||||||
|
limit_window_seconds: 7 * 24 * 60 * 60,
|
||||||
|
reset_at: weeklyResetAtMs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockReadFile.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("codex")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
tokens: {
|
||||||
|
access_token: "test-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error("File not found"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||||
|
mockRequest.mockImplementation((_options: any, callback: any) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
on: vi.fn((event: string, handler: any) => {
|
||||||
|
if (event === "data") handler(Buffer.from(JSON.stringify(mockResponse)));
|
||||||
|
if (event === "end") handler();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
callback(mockRes);
|
||||||
|
return mockReq;
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const codex = providers.find((p) => p.name === "Codex")!;
|
||||||
|
const weeklyWindow = codex.windows.find((w) => w.label === "Weekly")!;
|
||||||
|
|
||||||
|
expect(weeklyWindow.resetMs).toBeGreaterThan(4.9 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(weeklyWindow.resetMs).toBeLessThanOrEqual(5 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(weeklyWindow.pace).toBeDefined();
|
||||||
|
expect(weeklyWindow.pace!.percentElapsed).toBe(29);
|
||||||
|
expect(weeklyWindow.pace!.status).toBe("ahead");
|
||||||
|
expect(weeklyWindow.pace!.message).toBe("11% over pace");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Gemini provider", () => {
|
describe("Gemini provider", () => {
|
||||||
|
|||||||
@@ -1191,11 +1191,11 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
let resetAt: string | undefined;
|
let resetAt: string | undefined;
|
||||||
if (win.reset_at) {
|
const parsedReset = _parseResetTimestamp(win.reset_at);
|
||||||
const msLeft = win.reset_at * 1000 - Date.now();
|
if (parsedReset) {
|
||||||
resetMs = msLeft > 0 ? msLeft : 0;
|
resetMs = parsedReset.msLeft;
|
||||||
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
resetText = `resets in ${formatDuration(parsedReset.msLeft)}`;
|
||||||
resetAt = new Date(win.reset_at * 1000).toISOString();
|
resetAt = parsedReset.resetAt;
|
||||||
} else if (win.reset_after_seconds) {
|
} else if (win.reset_after_seconds) {
|
||||||
resetMs = win.reset_after_seconds * 1000;
|
resetMs = win.reset_after_seconds * 1000;
|
||||||
resetText = `resets in ${formatDuration(resetMs)}`;
|
resetText = `resets in ${formatDuration(resetMs)}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user