From 4411b446b90960ef719c43018242ad7dd98551b1 Mon Sep 17 00:00:00 2001 From: olegberman Date: Wed, 29 Jul 2026 22:03:40 -0400 Subject: [PATCH] Show update progress feedback --- CHANGELOG.md | 8 +++ app/app.tsx | 1 + app/components/layout/app-shell.tsx | 2 + app/features/pwa/pwa-lifecycle.tsx | 10 ++- app/features/pwa/register.ts | 2 + app/features/pwa/update-ready.ts | 17 +++++ app/features/updates/update-banner.tsx | 21 ++++-- app/features/updates/update-settings.tsx | 66 ++++++++++++++---- app/features/updates/use-update-monitor.ts | 11 ++- app/lib/notification-sounds.ts | 6 +- package.json | 2 +- public/sounds/update-ready.wav | Bin 0 -> 12394 bytes scripts/generate-notification-sounds.mjs | 5 ++ test/unit/app/notification-sounds.test.ts | 4 +- test/unit/app/pwa/pwa-lifecycle.test.tsx | 43 ++++++++++++ test/unit/app/pwa/register.test.ts | 33 +++++++++ test/unit/app/updates/update-banner.test.tsx | 29 +++++++- .../unit/app/updates/update-settings.test.tsx | 4 +- .../app/updates/use-update-monitor.test.tsx | 5 ++ 19 files changed, 239 insertions(+), 30 deletions(-) create mode 100644 app/features/pwa/update-ready.ts create mode 100644 public/sounds/update-ready.wav create mode 100644 test/unit/app/pwa/pwa-lifecycle.test.tsx 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 0000000000000000000000000000000000000000..868e5436ce427822ced395b2f278dd9e8a9c1fc3 GIT binary patch literal 12394 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 literal 0 HcmV?d00001 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"); }); });