Files
gacha-event-tracker/src/client/components/UpdateNotice.tsx
T
Lucas WintherandClaude Opus 5 a4ab5aa36c feat(sw): offer a reload when a newer version is ready
The shell is served cache-first, which is what makes the app work on a
train and also what makes a deploy invisible: a reader with the tab open
— the reader this app is built for — keeps running the bundle they first
loaded, so a new game or a corrected date reaches their device and sits
there with nothing saying why the page looks unchanged. An old app shown
as current is the same failure as old events shown as current.

So the worker now installs quietly and waits instead of calling
skipWaiting(), the page notices it waiting and says so, and the reader's
tap sends the skip-waiting message and reloads on controllerchange. The
app never reloads itself: someone may be mid-way through typing in one of
their own events, and the notice says what a reload costs (their place on
the page) and what it does not (marks and notes live in localStorage).

Detection is derived rather than remembered. build:static grew into a
script that stamps sw.js with a hash of the built shell, because the
browser only offers a worker whose bytes differ, and the predecessor —
a hand-bumped CACHE_VERSION — had already been forgotten once. The feed
is deliberately not part of that hash: it changes twice a day, needs no
reload, and announcing it would teach readers to dismiss the notice
unread. The cache name stays put for the same reason a per-build one
would be wrong — it holds the feed an offline reader is reading.

A first install is not an update and stays silent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:55:08 +02:00

69 lines
2.4 KiB
TypeScript

/**
* "A new version is ready."
*
* The one thing this app sells is that what it shows you is current, and a
* cache-first shell quietly breaks that promise for its most loyal reader — the
* one who never closes the tab. This is the disclosure, in the same spirit as
* the offline banner: say what is true, then let them choose.
*
* It is an offer, never a swap. Reloading mid-sentence while someone types their
* own event in would be the app taking a decision that costs them work, so the
* reader picks the moment. Dismissing is free — nothing they have marked, typed
* or ticked lives in the bundle, and the offer comes back next load.
*
* Sits a toast's height up from the bottom edge so an undo toast can never land
* on top of it, and low enough to be reachable with a thumb.
*/
export function UpdateNotice({
applying,
onApply,
onDismiss,
}: {
applying: boolean;
onApply: () => void;
onDismiss: () => void;
}) {
return (
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4"
>
<div className="pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-hairline bg-raised px-4 py-3 shadow-lg">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-near" />
<p className="min-w-0 flex-1 text-xs leading-relaxed text-muted">
A new version of Event Clock is ready.{" "}
<span className="text-faint">
Your marks and notes are kept — reloading only loses your place on
the page.
</span>
</p>
<button
type="button"
onClick={onApply}
disabled={applying}
className="shrink-0 rounded-md px-2 py-1 text-xs font-semibold text-near transition-colors duration-150 hover:text-ink disabled:text-faint"
>
{applying ? "Reloading…" : "Reload"}
</button>
<button
type="button"
onClick={onDismiss}
aria-label="Not now"
className="shrink-0 text-faint transition-colors duration-150 hover:text-muted"
>
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
<path
d="M4 4l8 8M12 4l-8 8"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
);
}