WFDASHDRIVER-APP/components/UpdateWatcher.tsx
Dave bd15f3146b feat: load a new build by itself after a deploy
The service worker served the cached page first, so a driver ran the
old build until the app was opened twice (Joachim on 2026-10-26 kept
the old one after the invoice-match deploy). Each build now writes its
id to /version.json. On launch a newer live build clears the cached
pages and reloads once; while the app stays open an Update banner
appears instead, so a reload never cuts off a photo or an upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:20:23 +02:00

109 lines
3.7 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { RefreshCw } from 'lucide-react';
const BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID ?? '';
const RELOADED_FOR = '1driver_reloaded_for';
const CHECK_EVERY_MS = 10 * 60 * 1000;
/** The build the server is serving now, or '' when unknown (offline, dev). */
async function liveBuildId(): Promise<string> {
try {
const res = await fetch(`/version.json?t=${Date.now()}`, { cache: 'no-store' });
if (!res.ok) return '';
const body = (await res.json()) as { id?: unknown };
return typeof body.id === 'string' ? body.id : '';
} catch {
return '';
}
}
/**
* Drop the cached pages (not the hashed /_next/static files) so the service
* worker fetches the new HTML, then reload. Without this the first reload
* after a deploy still shows the old cached page.
*/
async function loadNewVersion(): Promise<void> {
try {
for (const name of await caches.keys()) {
const cache = await caches.open(name);
for (const req of await cache.keys()) {
const url = new URL(req.url);
if (url.origin === location.origin && !url.pathname.startsWith('/_next/static/')) {
await cache.delete(req);
}
}
}
const reg = await navigator.serviceWorker?.getRegistration();
await reg?.update();
} catch {
// Reload anyway; the worst case is one more stale open.
}
location.reload();
}
// Keeps drivers on the latest build. On launch a new build loads straight
// away (once per build, so a stale server can't cause a reload loop). While
// the app stays open it only shows an Update banner: a reload then could cut
// off a camera photo or an upload in progress.
export default function UpdateWatcher() {
const [ready, setReady] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (process.env.NODE_ENV !== 'production' || !BUILD_ID) return;
let stopped = false;
const check = async (onLaunch: boolean) => {
const live = await liveBuildId();
if (stopped || !live || live === BUILD_ID) return;
let alreadyTried = false;
try {
alreadyTried = sessionStorage.getItem(RELOADED_FOR) === live;
if (onLaunch && !alreadyTried) sessionStorage.setItem(RELOADED_FOR, live);
} catch {
alreadyTried = true; // no storage: never auto-reload, the banner is enough
}
if (onLaunch && !alreadyTried) void loadNewVersion();
else setReady(true);
};
void check(true);
const onVisible = () => {
if (document.visibilityState === 'visible') void check(false);
};
document.addEventListener('visibilitychange', onVisible);
const timer = setInterval(() => void check(false), CHECK_EVERY_MS);
return () => {
stopped = true;
document.removeEventListener('visibilitychange', onVisible);
clearInterval(timer);
};
}, []);
if (!ready) return null;
return (
<div className="fixed bottom-[76px] left-3 right-3 z-[85] safe-bottom" role="status" aria-live="polite">
<div
className="flex items-center gap-3 rounded-2xl bg-[#26251e] text-white px-4 py-3"
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.25)' }}
>
<p className="flex-1 text-[14px] leading-snug">A new version of the app is ready.</p>
<button
type="button"
disabled={busy}
onClick={() => {
setBusy(true);
void loadNewVersion();
}}
className="h-10 px-4 rounded-xl bg-[#f54e00] text-[14px] font-semibold flex items-center gap-1.5 disabled:opacity-60 touch-manipulation"
>
<RefreshCw size={15} className={busy ? 'animate-spin' : ''} aria-hidden="true" />
Update
</button>
</div>
</div>
);
}