diff --git a/CHANGELOG.md b/CHANGELOG.md index 381fe91..be84c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.29 + +- Replace the available-update banner with an animated progress status after an update starts, then + remove it when the replacement application is ready to reload. +- Refine Settings -> Updates with clearer version hierarchy, deployment progress, and the retained + Cloudflare build reference. +- Play one quiet local notification sound when the new HQBase version is ready to reload. + ## 0.1.28 - Reply to or forward any expanded message in a conversation while preserving the existing diff --git a/app/app.tsx b/app/app.tsx index 2af26d5..cf160a5 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -143,6 +143,7 @@ export function App(): React.ReactElement { search={search} user={user} updateInProgress={updateMonitor.progress !== null} + updateReady={updateMonitor.ready} updateStatus={updateMonitor.status} onOpenUpdates={() => { navigate({ kind: "settings", tab: "updates" }); diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index de203a8..a35483e 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -18,6 +18,7 @@ type AppShellProps = { search: string; user: CurrentUser; updateInProgress: boolean; + updateReady: boolean; updateStatus: UpdateStatus | null; unread: UnreadCounts; draftCount: number; @@ -58,6 +59,7 @@ export function AppShell(props: AppShellProps): React.ReactElement { /> diff --git a/app/features/pwa/pwa-lifecycle.tsx b/app/features/pwa/pwa-lifecycle.tsx index 90c0408..46182d3 100644 --- a/app/features/pwa/pwa-lifecycle.tsx +++ b/app/features/pwa/pwa-lifecycle.tsx @@ -1,11 +1,12 @@ import * as React from "react"; - import { readUpdateProgress } from "@/features/updates/update-progress"; +import { playNotificationSound } from "@/lib/notification-sounds"; import { type PwaUpdate, registerPwa } from "./register"; export function PwaLifecycle(): React.ReactElement | null { const [online, setOnline] = React.useState(() => navigator.onLine); const [update, setUpdate] = React.useState(null); + const updateAnnouncedRef = React.useRef(false); React.useEffect(() => { const handleOnline = (): void => setOnline(true); @@ -15,7 +16,12 @@ export function PwaLifecycle(): React.ReactElement | null { const unregisterLifecycle = import.meta.env.PROD ? registerPwa({ - onUpdateReady: setUpdate, + onUpdateReady: (nextUpdate) => { + if (updateAnnouncedRef.current) return; + updateAnnouncedRef.current = true; + setUpdate(nextUpdate); + playNotificationSound("update-ready"); + }, watchForUpdate: readUpdateProgress() !== null }) : () => undefined; diff --git a/app/features/pwa/register.ts b/app/features/pwa/register.ts index 9ebb09e..057a22d 100644 --- a/app/features/pwa/register.ts +++ b/app/features/pwa/register.ts @@ -1,4 +1,5 @@ import { UPDATE_STARTED_EVENT } from "@/features/updates/update-progress"; +import { announcePwaUpdateReady } from "./update-ready"; export type PwaUpdate = { activate: () => void; @@ -34,6 +35,7 @@ export function registerPwa({ const announceWaitingWorker = (): void => { if (registration?.waiting && navigator.serviceWorker.controller) { stopActiveUpdateWatch(); + announcePwaUpdateReady(); onUpdateReady({ activate }); } }; diff --git a/app/features/pwa/update-ready.ts b/app/features/pwa/update-ready.ts new file mode 100644 index 0000000..437cea5 --- /dev/null +++ b/app/features/pwa/update-ready.ts @@ -0,0 +1,17 @@ +export const PWA_UPDATE_READY_EVENT = "hqbase:pwa-update-ready"; + +const readyAttribute = "data-hqbase-update-ready"; + +export function announcePwaUpdateReady(): void { + if (typeof document === "undefined" || document.documentElement.hasAttribute(readyAttribute)) { + return; + } + document.documentElement.setAttribute(readyAttribute, ""); + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(PWA_UPDATE_READY_EVENT)); + } +} + +export function readPwaUpdateReady(): boolean { + return typeof document !== "undefined" && document.documentElement.hasAttribute(readyAttribute); +} diff --git a/app/features/updates/update-banner.tsx b/app/features/updates/update-banner.tsx index c3e6bcc..445950c 100644 --- a/app/features/updates/update-banner.tsx +++ b/app/features/updates/update-banner.tsx @@ -1,18 +1,22 @@ import { ArrowUpCircle } from "lucide-react"; import type * as React from "react"; import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; import type { UpdateStatus } from "./types"; export function UpdateBanner({ inProgress, + ready, status, onOpen }: { inProgress: boolean; + ready: boolean; status: UpdateStatus | null; onOpen: () => void; }): React.ReactElement | null { - if (inProgress || !status?.available) return null; + if (ready || (!inProgress && !status?.available)) return null; + const targetVersion = status?.release.version; return (
-
); diff --git a/app/features/updates/update-settings.tsx b/app/features/updates/update-settings.tsx index a1f3f46..44b804f 100644 --- a/app/features/updates/update-settings.tsx +++ b/app/features/updates/update-settings.tsx @@ -1,7 +1,8 @@ +import { ArrowRight, RefreshCw } from "lucide-react"; import * as React from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; +import { Spinner } from "@/components/ui/spinner"; import { CloudflareAuthorizationDialog } from "@/features/settings/cloudflare-authorization-dialog"; import { SettingsSection } from "@/features/settings/settings-section"; import { applyUpdate, getUpdateStatus } from "./api"; @@ -91,33 +92,68 @@ export function UpdateSettings({ ) : null} {progress ? ( - - Update started - - Cloudflare build {progress.buildId} is running. - HQBase remains available during the build and will reconnect after deployment. - - +
+
+
+
+
+

Update in progress

+

+ {status?.release.version + ? `HQBase ${status.release.version} is being deployed. ` + : "The new version is being deployed. "} + You can keep working while Cloudflare finishes the build. +

+
+ Cloudflare build + + {progress.buildId} + +
+
+
+
) : null} -
- - +
+
+ +
{status?.available && !progress ? ( -
- +

Apply update

-

+

HQBase verifies the artifact, records the Worker version and D1 bookmark, migrates, deploys, and verifies before reporting success.

diff --git a/app/features/updates/use-update-monitor.ts b/app/features/updates/use-update-monitor.ts index 2db96bf..c21cd1e 100644 --- a/app/features/updates/use-update-monitor.ts +++ b/app/features/updates/use-update-monitor.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { PWA_UPDATE_READY_EVENT, readPwaUpdateReady } from "@/features/pwa/update-ready"; import { getUpdateStatus } from "./api"; import type { UpdateStatus } from "./types"; import { @@ -14,11 +15,13 @@ const activeUpdateIntervalMs = 10_000; export function useUpdateMonitor(canManage: boolean): { acceptStatus: (status: UpdateStatus) => void; progress: UpdateProgress | null; + ready: boolean; start: (buildId: string) => void; status: UpdateStatus | null; } { const [status, setStatus] = React.useState(null); const [progress, setProgress] = React.useState(() => readUpdateProgress()); + const [ready, setReady] = React.useState(readPwaUpdateReady); const acceptStatus = React.useCallback((nextStatus: UpdateStatus) => { setStatus(nextStatus); @@ -28,6 +31,12 @@ export function useUpdateMonitor(canManage: boolean): { } }, []); + React.useEffect(() => { + const handleUpdateReady = (): void => setReady(true); + window.addEventListener(PWA_UPDATE_READY_EVENT, handleUpdateReady); + return () => window.removeEventListener(PWA_UPDATE_READY_EVENT, handleUpdateReady); + }, []); + React.useEffect(() => { if (!canManage) { setStatus(null); @@ -72,5 +81,5 @@ export function useUpdateMonitor(canManage: boolean): { setProgress(beginUpdateProgress(buildId)); }, []); - return { acceptStatus, progress, start, status }; + return { acceptStatus, progress, ready, start, status }; } diff --git a/app/lib/notification-sounds.ts b/app/lib/notification-sounds.ts index 7680897..7345431 100644 --- a/app/lib/notification-sounds.ts +++ b/app/lib/notification-sounds.ts @@ -4,7 +4,8 @@ export type NotificationSound = | "toast-error" | "toast-information" | "toast-success" - | "toast-warning"; + | "toast-warning" + | "update-ready"; export type ToastSoundType = | "action" @@ -25,7 +26,8 @@ const SOUND_SOURCES: Record = { "toast-error": "/sounds/toast-error.wav", "toast-information": "/sounds/toast-information.wav", "toast-success": "/sounds/toast-success.wav", - "toast-warning": "/sounds/toast-warning.wav" + "toast-warning": "/sounds/toast-warning.wav", + "update-ready": "/sounds/update-ready.wav" }; let initialized = false; diff --git a/package.json b/package.json index 35c6ed1..0784836 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqbase", - "version": "0.1.28", + "version": "0.1.29", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", diff --git a/public/sounds/update-ready.wav b/public/sounds/update-ready.wav new file mode 100644 index 0000000..868e543 Binary files /dev/null and b/public/sounds/update-ready.wav differ diff --git a/scripts/generate-notification-sounds.mjs b/scripts/generate-notification-sounds.mjs index 36f8de6..6492f73 100644 --- a/scripts/generate-notification-sounds.mjs +++ b/scripts/generate-notification-sounds.mjs @@ -27,6 +27,11 @@ const sounds = { { gap: 45 }, { from: 440, to: 440, duration: 65 } ], + "update-ready": [ + { from: 520, to: 620, duration: 90 }, + { gap: 40 }, + { from: 660, to: 820, duration: 150 } + ], unlock: [{ gap: 30 }] }; diff --git a/test/unit/app/notification-sounds.test.ts b/test/unit/app/notification-sounds.test.ts index 2a88685..2e930b1 100644 --- a/test/unit/app/notification-sounds.test.ts +++ b/test/unit/app/notification-sounds.test.ts @@ -51,8 +51,10 @@ describe("notification sounds", () => { expect(audio.src).toBe("/sounds/toast-error.wav"); playNotificationSound("toast-information"); expect(audio.src).toBe("/sounds/toast-information.wav"); + playNotificationSound("update-ready"); + expect(audio.src).toBe("/sounds/update-ready.wav"); - expect(audio.play).toHaveBeenCalledTimes(7); + expect(audio.play).toHaveBeenCalledTimes(8); expect(audio.volume).toBe(0.55); }); }); diff --git a/test/unit/app/pwa/pwa-lifecycle.test.tsx b/test/unit/app/pwa/pwa-lifecycle.test.tsx new file mode 100644 index 0000000..8214d3d --- /dev/null +++ b/test/unit/app/pwa/pwa-lifecycle.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import { PwaLifecycle } from "@/features/pwa/pwa-lifecycle"; +import { flushHookEffects, renderComponent } from "../render-hook"; + +const mocks = vi.hoisted(() => ({ + onUpdateReady: null as null | ((update: { activate: () => void }) => void), + playNotificationSound: vi.fn(), + registerPwa: vi.fn( + ({ onUpdateReady }: { onUpdateReady: (update: { activate: () => void }) => void }) => { + mocks.onUpdateReady = onUpdateReady; + return vi.fn(); + } + ) +})); + +vi.mock("@/features/pwa/register", () => ({ + registerPwa: mocks.registerPwa +})); + +vi.mock("@/lib/notification-sounds", () => ({ + playNotificationSound: mocks.playNotificationSound +})); + +describe("PWA lifecycle", () => { + it("plays one sound when the ready-to-reload notice appears", async () => { + vi.stubEnv("PROD", true); + const activate = vi.fn(); + const view = await renderComponent(); + await flushHookEffects(() => mocks.onUpdateReady?.({ activate })); + + expect(view.container.textContent).toContain("A new version of HQBase is ready."); + expect(view.container.textContent).toContain("Reload"); + expect(mocks.playNotificationSound).toHaveBeenCalledOnce(); + expect(mocks.playNotificationSound).toHaveBeenCalledWith("update-ready"); + + await flushHookEffects(() => mocks.onUpdateReady?.({ activate })); + expect(mocks.playNotificationSound).toHaveBeenCalledOnce(); + await view.unmount(); + vi.unstubAllEnvs(); + }); +}); diff --git a/test/unit/app/pwa/register.test.ts b/test/unit/app/pwa/register.test.ts index ceabc00..cb762d7 100644 --- a/test/unit/app/pwa/register.test.ts +++ b/test/unit/app/pwa/register.test.ts @@ -1,9 +1,12 @@ +// @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; import { registerPwa } from "@/features/pwa/register"; +import { PWA_UPDATE_READY_EVENT } from "@/features/pwa/update-ready"; import { UPDATE_STARTED_EVENT } from "@/features/updates/update-progress"; describe("PWA registration", () => { afterEach(() => { + document.documentElement.removeAttribute("data-hqbase-update-ready"); vi.unstubAllGlobals(); }); @@ -51,4 +54,34 @@ describe("PWA registration", () => { unregister(); }); + + it("announces when the replacement worker is ready", async () => { + const onUpdateReady = vi.fn(); + const readyListener = vi.fn(); + const waiting = { postMessage: vi.fn() }; + const registration = { + addEventListener: vi.fn(), + installing: null, + update: vi.fn().mockResolvedValue(undefined), + waiting + }; + const serviceWorker = { + addEventListener: vi.fn(), + controller: {}, + register: vi.fn().mockResolvedValue(registration), + removeEventListener: vi.fn() + }; + vi.stubGlobal("navigator", { onLine: true, serviceWorker }); + window.addEventListener(PWA_UPDATE_READY_EVENT, readyListener); + + const unregister = registerPwa({ onUpdateReady }); + await Promise.resolve(); + + expect(onUpdateReady).toHaveBeenCalledOnce(); + expect(readyListener).toHaveBeenCalledOnce(); + expect(document.documentElement.hasAttribute("data-hqbase-update-ready")).toBe(true); + + unregister(); + window.removeEventListener(PWA_UPDATE_READY_EVENT, readyListener); + }); }); diff --git a/test/unit/app/updates/update-banner.test.tsx b/test/unit/app/updates/update-banner.test.tsx index 5e100e2..35fc7ba 100644 --- a/test/unit/app/updates/update-banner.test.tsx +++ b/test/unit/app/updates/update-banner.test.tsx @@ -11,7 +11,7 @@ describe("update banner", () => { release: { version: "0.2.0" } } as UpdateStatus; const html = renderToStaticMarkup( - undefined} /> + undefined} /> ); expect(html).toContain("Update available"); expect(html).toContain("0.2.0"); @@ -21,14 +21,37 @@ describe("update banner", () => { expect(html).not.toContain("blue-"); }); - it("stays hidden after the update starts", () => { + it("becomes an animated progress banner after the update starts", () => { const status = { installedVersion: "0.1.0", available: true, release: { version: "0.2.0" } } as UpdateStatus; const html = renderToStaticMarkup( - undefined} /> + undefined} /> + ); + expect(html).toContain("Update in progress"); + expect(html).toContain("animate-spin"); + expect(html).toContain("View progress"); + expect(html).not.toContain("Update available"); + }); + + it("keeps persisted update progress visible while status refreshes", () => { + const html = renderToStaticMarkup( + undefined} /> + ); + expect(html).toContain("Update in progress"); + expect(html).toContain("View progress"); + }); + + it("disappears when the replacement version is ready to reload", () => { + const status = { + installedVersion: "0.1.0", + available: true, + release: { version: "0.2.0" } + } as UpdateStatus; + const html = renderToStaticMarkup( + undefined} /> ); expect(html).toBe(""); }); diff --git a/test/unit/app/updates/update-settings.test.tsx b/test/unit/app/updates/update-settings.test.tsx index ca55e72..e7702b4 100644 --- a/test/unit/app/updates/update-settings.test.tsx +++ b/test/unit/app/updates/update-settings.test.tsx @@ -60,7 +60,9 @@ describe("update settings", () => { onUpdateStarted={() => undefined} /> ); - expect(html).toContain("Update started"); + expect(html).toContain("Update in progress"); + expect(html).toContain("animate-spin"); + expect(html).toContain("HQBase 0.2.0 is being deployed"); expect(html).toContain("build-123"); expect(html).not.toContain("Install update"); }); diff --git a/test/unit/app/updates/use-update-monitor.test.tsx b/test/unit/app/updates/use-update-monitor.test.tsx index 748c9c2..26a2797 100644 --- a/test/unit/app/updates/use-update-monitor.test.tsx +++ b/test/unit/app/updates/use-update-monitor.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { describe, expect, it, vi } from "vitest"; +import { announcePwaUpdateReady } from "@/features/pwa/update-ready"; import type { UpdateStatus } from "@/features/updates/types"; import { useUpdateMonitor } from "@/features/updates/use-update-monitor"; import { flushHookEffects, renderHook } from "../render-hook"; @@ -46,6 +47,9 @@ describe("useUpdateMonitor", () => { expect(mocks.getUpdateStatus).toHaveBeenCalledOnce(); expect(hook.result.status).toEqual(status); + expect(hook.result.ready).toBe(false); + await flushHookEffects(announcePwaUpdateReady); + expect(hook.result.ready).toBe(true); await flushHookEffects(() => window.dispatchEvent(new Event("focus"))); expect(mocks.getUpdateStatus).toHaveBeenCalledTimes(2); @@ -54,5 +58,6 @@ describe("useUpdateMonitor", () => { await flushHookEffects(() => window.dispatchEvent(new Event("focus"))); expect(mocks.getUpdateStatus).toHaveBeenCalledTimes(2); await hook.unmount(); + document.documentElement.removeAttribute("data-hqbase-update-ready"); }); });