Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
2 changes: 2 additions & 0 deletions app/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type AppShellProps = {
search: string;
user: CurrentUser;
updateInProgress: boolean;
updateReady: boolean;
updateStatus: UpdateStatus | null;
unread: UnreadCounts;
draftCount: number;
Expand Down Expand Up @@ -58,6 +59,7 @@ export function AppShell(props: AppShellProps): React.ReactElement {
/>
<UpdateBanner
inProgress={props.updateInProgress}
ready={props.updateReady}
status={props.updateStatus}
onOpen={props.onOpenUpdates}
/>
Expand Down
10 changes: 8 additions & 2 deletions app/features/pwa/pwa-lifecycle.tsx
Original file line number Diff line number Diff line change
@@ -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<PwaUpdate | null>(null);
const updateAnnouncedRef = React.useRef(false);

React.useEffect(() => {
const handleOnline = (): void => setOnline(true);
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions app/features/pwa/register.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { UPDATE_STARTED_EVENT } from "@/features/updates/update-progress";
import { announcePwaUpdateReady } from "./update-ready";

export type PwaUpdate = {
activate: () => void;
Expand Down Expand Up @@ -34,6 +35,7 @@ export function registerPwa({
const announceWaitingWorker = (): void => {
if (registration?.waiting && navigator.serviceWorker.controller) {
stopActiveUpdateWatch();
announcePwaUpdateReady();
onUpdateReady({ activate });
}
};
Expand Down
17 changes: 17 additions & 0 deletions app/features/pwa/update-ready.ts
Original file line number Diff line number Diff line change
@@ -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);
}
21 changes: 17 additions & 4 deletions app/features/updates/update-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,45 @@
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 (
<div
aria-live="polite"
className="flex min-h-10 shrink-0 items-center justify-between gap-3 border-b bg-muted/45 px-3 text-xs md:px-4"
role="status"
>
<div className="flex items-center gap-2">
<ArrowUpCircle aria-hidden="true" className="size-4 text-muted-foreground" />
{inProgress ? (
<Spinner
aria-hidden="true"
className="size-3.5 text-muted-foreground"
role="presentation"
/>
) : (
<ArrowUpCircle aria-hidden="true" className="size-4 text-muted-foreground" />
)}
<span>
<strong>Update available</strong> · HQBase {status.release.version}
<strong>{inProgress ? "Update in progress" : "Update available"}</strong>
{targetVersion ? ` · HQBase ${targetVersion}` : null}
</span>
</div>
<Button className="h-7 px-3 text-xs" onClick={onOpen} type="button" variant="outline">
Review update
{inProgress ? "View progress" : "Review update"}
</Button>
</div>
);
Expand Down
66 changes: 51 additions & 15 deletions app/features/updates/update-settings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -91,33 +92,68 @@ export function UpdateSettings({
</Alert>
) : null}
{progress ? (
<Alert>
<AlertTitle>Update started</AlertTitle>
<AlertDescription>
Cloudflare build <span className="font-mono">{progress.buildId}</span> is running.
HQBase remains available during the build and will reconnect after deployment.
</AlertDescription>
</Alert>
<div
aria-live="polite"
className="rounded-xl border border-border/80 bg-muted/25 p-4 sm:p-5"
role="status"
>
<div className="flex items-start gap-3.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full border bg-background shadow-sm">
<Spinner aria-hidden="true" className="size-4 text-foreground" role="presentation" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium">Update in progress</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{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.
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span>Cloudflare build</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground ring-1 ring-border">
{progress.buildId}
</code>
</div>
</div>
</div>
</div>
) : null}
<div aria-live="polite" className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
<Version label="Current" value={status?.installedVersion ?? "Unknown"} />
<Version label="Available" value={status?.release.version ?? "Not checked"} />
<div
aria-live="polite"
className="flex flex-col gap-3 border-y py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs">
<Version label="Current" value={status?.installedVersion ?? "Unknown"} />
<ArrowRight aria-hidden="true" className="size-3.5 text-muted-foreground/70" />
<Version label="Available" value={status?.release.version ?? "Not checked"} />
</div>
<Button
className="self-start sm:self-auto"
disabled={isPending}
onClick={() => void check()}
size="sm"
type="button"
variant="ghost"
>
{pendingAction === "check" ? "Checking…" : "Check updates"}
{pendingAction === "check" ? (
<>
<Spinner aria-hidden="true" role="presentation" />
Checking…
</>
) : (
<>
<RefreshCw aria-hidden="true" />
Check updates
</>
)}
</Button>
</div>
{status?.available && !progress ? (
<div className="flex flex-col gap-4">
<Separator />
<div className="flex flex-col gap-4 pt-1">
<div>
<h3 className="text-sm font-medium">Apply update</h3>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">
HQBase verifies the artifact, records the Worker version and D1 bookmark, migrates,
deploys, and verifies before reporting success.
</p>
Expand Down
11 changes: 10 additions & 1 deletion app/features/updates/use-update-monitor.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<UpdateStatus | null>(null);
const [progress, setProgress] = React.useState<UpdateProgress | null>(() => readUpdateProgress());
const [ready, setReady] = React.useState(readPwaUpdateReady);

const acceptStatus = React.useCallback((nextStatus: UpdateStatus) => {
setStatus(nextStatus);
Expand All @@ -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);
Expand Down Expand Up @@ -72,5 +81,5 @@ export function useUpdateMonitor(canManage: boolean): {
setProgress(beginUpdateProgress(buildId));
}, []);

return { acceptStatus, progress, start, status };
return { acceptStatus, progress, ready, start, status };
}
6 changes: 4 additions & 2 deletions app/lib/notification-sounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export type NotificationSound =
| "toast-error"
| "toast-information"
| "toast-success"
| "toast-warning";
| "toast-warning"
| "update-ready";

export type ToastSoundType =
| "action"
Expand All @@ -25,7 +26,8 @@ const SOUND_SOURCES: Record<NotificationSound, string> = {
"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;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hqbase",
"version": "0.1.28",
"version": "0.1.29",
"private": true,
"type": "module",
"packageManager": "pnpm@11.7.0",
Expand Down
Binary file added public/sounds/update-ready.wav
Binary file not shown.
5 changes: 5 additions & 0 deletions scripts/generate-notification-sounds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]
};

Expand Down
4 changes: 3 additions & 1 deletion test/unit/app/notification-sounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
43 changes: 43 additions & 0 deletions test/unit/app/pwa/pwa-lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PwaLifecycle />);
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();
});
});
Loading