diff --git a/packages/web/declaration.d.ts b/packages/web/declaration.d.ts index 5b5bf1821..465277980 100644 --- a/packages/web/declaration.d.ts +++ b/packages/web/declaration.d.ts @@ -26,3 +26,5 @@ declare module "*.jpg" { declare module "*.jpeg" { export = imageUrl; } + +declare const BUILD_VERSION: string; diff --git a/packages/web/src/common/hooks/useVersionCheck.test.ts b/packages/web/src/common/hooks/useVersionCheck.test.ts new file mode 100644 index 000000000..19eb9b8c3 --- /dev/null +++ b/packages/web/src/common/hooks/useVersionCheck.test.ts @@ -0,0 +1,268 @@ +import { act } from "react"; +import { renderHook } from "@testing-library/react"; +import { useVersionCheck } from "@web/common/hooks/useVersionCheck"; + +let mockIsDev = false; +jest.mock("@web/common/constants/env.constants", () => ({ + get IS_DEV() { + return mockIsDev; + }, +})); + +const MIN_HIDDEN_DURATION_MS = 30_000; +const BACKUP_CHECK_INTERVAL_MS = 5 * 60 * 1000; + +describe("useVersionCheck", () => { + let visibilityState = "visible"; + const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + beforeEach(() => { + mockIsDev = false; + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-02-05T00:00:00.000Z")); + + visibilityState = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState, + }); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: "dev" }), + }) as typeof fetch; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it("checks version on initial mount", async () => { + renderHook(() => useVersionCheck()); + await act(async () => { + await flushPromises(); + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringMatching(/^\/version\.json\?t=\d+$/), + expect.objectContaining({ + cache: "no-store", + headers: { "Cache-Control": "no-cache" }, + }), + ); + }); + + it("does not check for updates in development mode", async () => { + mockIsDev = true; + const addEventListenerSpy = jest.spyOn(document, "addEventListener"); + const setIntervalSpy = jest.spyOn(window, "setInterval"); + + renderHook(() => useVersionCheck()); + await act(async () => { + await flushPromises(); + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(addEventListenerSpy).not.toHaveBeenCalled(); + expect(setIntervalSpy).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(BACKUP_CHECK_INTERVAL_MS); + }); + + expect(global.fetch).not.toHaveBeenCalled(); + + addEventListenerSpy.mockRestore(); + setIntervalSpy.mockRestore(); + }); + + it("sets isUpdateAvailable when server version differs", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: "1.0.0" }), + }) as typeof fetch; + + const { result } = renderHook(() => useVersionCheck()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.isUpdateAvailable).toBe(true); + }); + + it("keeps isUpdateAvailable false when versions match", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: "dev" }), + }) as typeof fetch; + + const { result } = renderHook(() => useVersionCheck()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.isUpdateAvailable).toBe(false); + }); + + it("handles network failures without crashing", async () => { + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + global.fetch = jest + .fn() + .mockRejectedValue(new Error("Network down")) as typeof fetch; + + const { result } = renderHook(() => useVersionCheck()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.isUpdateAvailable).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Version check failed:", + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + + it("ignores invalid version payloads", async () => { + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: 123 }), + }) as typeof fetch; + + const { result } = renderHook(() => useVersionCheck()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.isUpdateAvailable).toBe(false); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it("checks when tab becomes visible after being hidden long enough", async () => { + renderHook(() => useVersionCheck()); + await act(async () => { + await flushPromises(); + }); + (global.fetch as jest.Mock).mockClear(); + + act(() => { + visibilityState = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + jest.advanceTimersByTime(MIN_HIDDEN_DURATION_MS + 1_000); + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("does not check when tab becomes visible after a short hide", async () => { + renderHook(() => useVersionCheck()); + await act(async () => { + await flushPromises(); + }); + (global.fetch as jest.Mock).mockClear(); + + act(() => { + visibilityState = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + jest.advanceTimersByTime(MIN_HIDDEN_DURATION_MS - 10_000); + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("cleans up the visibility listener on unmount", () => { + const addEventListenerSpy = jest.spyOn(document, "addEventListener"); + const removeEventListenerSpy = jest.spyOn(document, "removeEventListener"); + + const { unmount } = renderHook(() => useVersionCheck()); + + const handler = addEventListenerSpy.mock.calls.find( + ([eventName]) => eventName === "visibilitychange", + )?.[1]; + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "visibilitychange", + handler, + ); + + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + + it("runs the backup poll on the interval", async () => { + renderHook(() => useVersionCheck()); + await act(async () => { + await flushPromises(); + }); + (global.fetch as jest.Mock).mockClear(); + + act(() => { + jest.advanceTimersByTime(BACKUP_CHECK_INTERVAL_MS); + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("prevents concurrent checks", async () => { + let resolveFetch: ((value: unknown) => void) | undefined; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + renderHook(() => useVersionCheck()); + await act(async () => { + await flushPromises(); + }); + + global.fetch = jest.fn().mockReturnValue(fetchPromise) as typeof fetch; + (global.fetch as jest.Mock).mockClear(); + + act(() => { + visibilityState = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + jest.advanceTimersByTime(MIN_HIDDEN_DURATION_MS + 1_000); + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + + visibilityState = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + jest.advanceTimersByTime(MIN_HIDDEN_DURATION_MS + 1_000); + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + + act(() => { + resolveFetch?.({ + ok: true, + json: async () => ({ version: "dev" }), + }); + }); + }); +}); diff --git a/packages/web/src/common/hooks/useVersionCheck.ts b/packages/web/src/common/hooks/useVersionCheck.ts new file mode 100644 index 000000000..6737366e7 --- /dev/null +++ b/packages/web/src/common/hooks/useVersionCheck.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { z } from "zod"; +import { IS_DEV } from "@web/common/constants/env.constants"; + +const MIN_HIDDEN_DURATION_MS = 30_000; +const BACKUP_CHECK_INTERVAL_MS = 5 * 60 * 1000; +const CURRENT_VERSION = + typeof BUILD_VERSION === "string" ? BUILD_VERSION : "dev"; +const versionResponseSchema = z.object({ + version: z.string().optional(), +}); + +export interface VersionCheckResult { + isUpdateAvailable: boolean; + currentVersion: string; +} + +/** + * Checks for new application versions by polling `/version.json`. + * + * Performs version checks: + * - On initial mount + * - When the tab becomes visible after being hidden for 30+ seconds + * - Every 5 minutes as a backup poll + * + * Disabled in development mode. + */ +export const useVersionCheck = (): VersionCheckResult => { + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); + const hiddenAtRef = useRef(null); + const isCheckingRef = useRef(false); + + const checkVersion = useCallback(async () => { + if (isCheckingRef.current) { + return; + } + + isCheckingRef.current = true; + + try { + const response = await fetch(`/version.json?t=${Date.now()}`, { + cache: "no-store", + headers: { "Cache-Control": "no-cache" }, + }); + + if (!response.ok) { + return; + } + + const parsedResponse = versionResponseSchema.safeParse( + await response.json(), + ); + + if (!parsedResponse.success) { + return; + } + + const { version: serverVersion } = parsedResponse.data; + + if (!serverVersion) { + return; + } + + setIsUpdateAvailable(serverVersion !== CURRENT_VERSION); + } catch (error) { + console.error("Version check failed:", error); + } finally { + isCheckingRef.current = false; + } + }, []); + + useEffect(() => { + if (IS_DEV) { + return; + } + + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + hiddenAtRef.current = Date.now(); + return; + } + + if (document.visibilityState !== "visible") { + return; + } + + const hiddenAt = hiddenAtRef.current; + hiddenAtRef.current = null; + + if (hiddenAt === null) { + return; + } + + const hiddenDuration = Date.now() - hiddenAt; + if (hiddenDuration >= MIN_HIDDEN_DURATION_MS) { + checkVersion(); + } + }; + + checkVersion(); + + document.addEventListener("visibilitychange", handleVisibilityChange); + const backupInterval = window.setInterval( + checkVersion, + BACKUP_CHECK_INTERVAL_MS, + ); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + clearInterval(backupInterval); + }; + }, [checkVersion]); + + return { isUpdateAvailable, currentVersion: CURRENT_VERSION }; +}; diff --git a/packages/web/src/common/utils/cleanup/browser.cleanup.util.ts b/packages/web/src/common/utils/cleanup/browser.cleanup.util.ts index 2d5eb449b..ee977b7f2 100644 --- a/packages/web/src/common/utils/cleanup/browser.cleanup.util.ts +++ b/packages/web/src/common/utils/cleanup/browser.cleanup.util.ts @@ -47,8 +47,6 @@ export async function clearAllBrowserStorage(): Promise { }); } } - - console.log("Browser storage cleared successfully"); } catch (error) { console.error("Error clearing browser storage:", error); throw error; diff --git a/packages/web/src/components/Icons/Refresh.tsx b/packages/web/src/components/Icons/Refresh.tsx new file mode 100644 index 000000000..e3a39b1fa --- /dev/null +++ b/packages/web/src/components/Icons/Refresh.tsx @@ -0,0 +1,7 @@ +import styled from "styled-components"; +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { iconStyles } from "./styled"; + +export const RefreshIcon = styled(ArrowsClockwiseIcon)` + ${iconStyles} +`; diff --git a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx index 0afaccc00..cf1f681df 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/SidebarIconRow.tsx @@ -1,7 +1,9 @@ +import { useVersionCheck } from "@web/common/hooks/useVersionCheck"; import { theme } from "@web/common/styles/theme"; import { getModifierKeyIcon } from "@web/common/utils/shortcut/shortcut.util"; import { CalendarIcon } from "@web/components/Icons/Calendar"; import { CommandIcon } from "@web/components/Icons/Command"; +import { RefreshIcon } from "@web/components/Icons/Refresh"; import { SpinnerIcon } from "@web/components/Icons/Spinner"; import { TodoIcon } from "@web/components/Icons/Todo"; import { Text } from "@web/components/Text"; @@ -22,6 +24,11 @@ export const SidebarIconRow = () => { const tab = useAppSelector(selectSidebarTab); const gCalImport = useAppSelector(selectImportGCalState); const isCmdPaletteOpen = useAppSelector(selectIsCmdPaletteOpen); + const { isUpdateAvailable } = useVersionCheck(); + + const handleUpdateReload = () => { + window.location.reload(); + }; const toggleCmdPalette = () => { if (isCmdPaletteOpen) { @@ -85,7 +92,15 @@ export const SidebarIconRow = () => { {gCalImport.importing ? ( - + + + ) : undefined} + {isUpdateAvailable ? ( + + ) : undefined} diff --git a/packages/web/webpack.config.mjs b/packages/web/webpack.config.mjs index 17710a6c0..0a8eaaf67 100644 --- a/packages/web/webpack.config.mjs +++ b/packages/web/webpack.config.mjs @@ -1,3 +1,4 @@ +import { execSync } from "child_process"; import dotenv from "dotenv"; import fs from "fs"; import HtmlWebpackPlugin from "html-webpack-plugin"; @@ -65,6 +66,9 @@ const loadEnvFile = (envName) => { */ export default (env, argv) => { const IS_DEV = argv.mode === "development"; + // git hash makes the build traceable + const GIT_HASH = execSync("git rev-parse --short HEAD").toString().trim(); + const BUILD_VERSION = IS_DEV ? "dev" : `${Date.now()}-${GIT_HASH}`; const ENVIRONMENT = argv.nodeEnv || "local"; loadEnvFile(ENVIRONMENT); @@ -110,6 +114,7 @@ export default (env, argv) => { // Define process.env as an object literal (not a JSON string) // This allows both process.env.KEY and process.env["KEY"] bracket notation to work "process.env": envObject, + BUILD_VERSION: JSON.stringify(BUILD_VERSION), }), new HtmlWebpackPlugin({ filename: "index.html", @@ -129,6 +134,28 @@ export default (env, argv) => { _plugins.push(new BundleAnalyzerPlugin()); } + if (!IS_DEV) { + _plugins.push({ + apply: (compiler) => { + compiler.hooks.emit.tapAsync( + "GenerateVersionPlugin", + (compilation, callback) => { + const versionContent = JSON.stringify( + { version: BUILD_VERSION }, + null, + 2, + ); + compilation.assets["version.json"] = { + source: () => versionContent, + size: () => versionContent.length, + }; + callback(); + }, + ); + }, + }); + } + return { entry: "./src/index.tsx", // got devtool sourcemap errors with: eval, eval-cheap-source-map