Skip to content

Commit d834d7c

Browse files
committed
fix: recheck updates while app stays open
1 parent d27c5a8 commit d834d7c

2 files changed

Lines changed: 97 additions & 2 deletions

File tree

‎src/App.test.tsx‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
55
import { StrictMode } from "react";
6-
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
77
import App from "./App";
88
import i18n from "./i18n";
99
import tauriConfig from "../src-tauri/tauri.conf.json";
@@ -181,6 +181,10 @@ function mockLoadedDashboard() {
181181
}
182182

183183
describe("App", () => {
184+
afterEach(() => {
185+
vi.useRealTimers();
186+
});
187+
184188
beforeEach(() => {
185189
localStorage.clear();
186190
invokeMock.mockReset();
@@ -231,6 +235,39 @@ describe("App", () => {
231235
expect(screen.queryByRole("button", { name: "Reset cache" })).not.toBeInTheDocument();
232236
});
233237

238+
it("checks for updates again after the app stays open for 24 hours", async () => {
239+
vi.useFakeTimers();
240+
vi.setSystemTime(new Date("2026-08-03T00:00:00.000Z"));
241+
localStorage.setItem("last_update_check_time", Date.now().toString());
242+
localStorage.setItem("last_update_check_result", JSON.stringify({
243+
hasUpdate: false,
244+
currentVersion: tauriConfig.version,
245+
latestVersion: tauriConfig.version,
246+
latestTag: `v${tauriConfig.version}`,
247+
releaseName: null,
248+
releaseNotes: null,
249+
releaseUrl: "",
250+
}));
251+
mockLoadedDashboard();
252+
253+
render(<App />);
254+
255+
await act(async () => {
256+
await vi.advanceTimersByTimeAsync(0);
257+
});
258+
expect(invokeMock).not.toHaveBeenCalledWith("check_for_updates");
259+
260+
await act(async () => {
261+
await vi.advanceTimersByTimeAsync(24 * 60 * 60_000 - 1);
262+
});
263+
expect(invokeMock).not.toHaveBeenCalledWith("check_for_updates");
264+
265+
await act(async () => {
266+
await vi.advanceTimersByTimeAsync(1);
267+
});
268+
expect(invokeMock).toHaveBeenCalledWith("check_for_updates");
269+
});
270+
234271
it("prevents the default page context menu", () => {
235272
invokeMock.mockImplementation(async (command: string, args?: { range?: string }) => {
236273
if (command === "fetch_overview" && args?.range === "30d") {

‎src/hooks/use-usage-dashboard.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ function isNewerVersion(current: string, target: string): boolean {
5050
}
5151

5252
const AUTO_RESCAN_MS = 5 * 60_000;
53+
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
54+
const UPDATE_CHECK_RETRY_MS = 60 * 60_000;
5355
const CODEX_QUOTA_FORECAST_URL = "https://www.willcodexquotareset.com/";
5456
const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage";
5557

@@ -249,6 +251,7 @@ export function useUsageDashboard() {
249251
const lastLimitsFetchTimeRef = useRef<number>(0);
250252
const lastAutoScanTimeRef = useRef<number>(0);
251253
const scanInFlightRef = useRef<Promise<void> | null>(null);
254+
const updateCheckInFlightRef = useRef<Promise<void> | null>(null);
252255

253256
const loadOverview = useEffectEvent(async (nextRange: RangeKey) => {
254257
const data = await fetchOverview(nextRange);
@@ -363,7 +366,7 @@ export function useUsageDashboard() {
363366
}
364367
});
365368

366-
const runBackgroundUpdateCheck = useEffectEvent(async () => {
369+
const performBackgroundUpdateCheck = useEffectEvent(async () => {
367370
let cachedInfo: UpdateCheckResponse | null = null;
368371
try {
369372
const now = Date.now();
@@ -473,6 +476,20 @@ export function useUsageDashboard() {
473476
}
474477
});
475478

479+
const runBackgroundUpdateCheck = useEffectEvent(async () => {
480+
if (!updateCheckInFlightRef.current) {
481+
const checkPromise = performBackgroundUpdateCheck();
482+
updateCheckInFlightRef.current = checkPromise;
483+
void checkPromise.finally(() => {
484+
if (updateCheckInFlightRef.current === checkPromise) {
485+
updateCheckInFlightRef.current = null;
486+
}
487+
});
488+
}
489+
490+
await updateCheckInFlightRef.current;
491+
});
492+
476493
const bootstrap = useEffectEvent(async () => {
477494
if (hasBootstrappedRef.current) {
478495
return;
@@ -532,6 +549,47 @@ export function useUsageDashboard() {
532549
void bootstrap();
533550
}, [bootstrap]);
534551

552+
useEffect(() => {
553+
if (!bootstrapped) return;
554+
555+
let cancelled = false;
556+
let timer: number | null = null;
557+
558+
const readTimestamp = (key: string) => {
559+
const value = Number(localStorage.getItem(key));
560+
return Number.isFinite(value) && value > 0 ? value : null;
561+
};
562+
563+
const scheduleNextCheck = () => {
564+
const now = Date.now();
565+
const lastSuccess = localStorage.getItem("last_update_check_result")
566+
? readTimestamp("last_update_check_time")
567+
: null;
568+
const lastFailure = readTimestamp("last_update_check_failed_time");
569+
const nextCheckAt = Math.max(
570+
lastSuccess ? lastSuccess + UPDATE_CHECK_INTERVAL_MS : now + UPDATE_CHECK_RETRY_MS,
571+
lastFailure ? lastFailure + UPDATE_CHECK_RETRY_MS : now,
572+
);
573+
574+
timer = window.setTimeout(() => {
575+
void runBackgroundUpdateCheck().finally(() => {
576+
if (!cancelled) {
577+
scheduleNextCheck();
578+
}
579+
});
580+
}, Math.max(0, nextCheckAt - now));
581+
};
582+
583+
scheduleNextCheck();
584+
585+
return () => {
586+
cancelled = true;
587+
if (timer !== null) {
588+
window.clearTimeout(timer);
589+
}
590+
};
591+
}, [bootstrapped, runBackgroundUpdateCheck]);
592+
535593
// Re-fetch usage when the page/window regains focus after being inactive ≥5 min.
536594
useEffect(() => {
537595
if (!bootstrapped) return;

0 commit comments

Comments
 (0)