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]>
This commit is contained in:
Lucas Winther
2026-08-17 18:55:08 +02:00
co-authored by Claude Opus 5
parent 7130a94b21
commit a4ab5aa36c
11 changed files with 804 additions and 20 deletions
+48
View File
@@ -95,6 +95,7 @@ src/
Controls.tsx games, region, export/import(F4, F5, F6)
Welcome.tsx first-run game picker (F8)
Toast.tsx undo an ignore
UpdateNotice a newer app is installed and waiting (F14)
Colophon.tsx credit, disclaimer, repo link
state/
storage.ts namespaced, versioned localStorage
@@ -104,9 +105,11 @@ src/
usePrefs.ts region, filters, focus, onboarding flags
sort.ts deadline order, or what you're partway through
lens.ts who sees which rows — focus, outstanding, next-to-expire
useAppUpdate.ts is a newer build waiting, and taking it (F14)
serve.ts static server + /api/health ✓ built
scripts/
build-feed.ts fixtures → public/data/events.v1.json ✓ built
build-static.ts shell + worker into public/, build-stamped ✓ built
parse-fixture.ts run one adapter offline ✓ built
fixtures/<game>/ checked-in raw HTML + expected parse output
```
@@ -200,6 +203,51 @@ back to the last copy seen). Countdowns run off the device clock, so the app sta
network. Offline state is surfaced in the header and above the footer — stale data must never be
presented as current.
### Shipping a new version to an open page
A cache-first shell is what makes the offline story work and what makes a deploy invisible: the
reader this app is built for leaves the tab open for days, so a new game, a repaired parser or a
corrected date reaches their device and then sits there unused. Presenting an old app as current is
the same failure as presenting old events as current, so it is disclosed the same way.
```
build:static ──► sw.js stamped with a hash of the built shell
browser byte-compares sw.js on navigation, hourly, and when the tab is
revealed (registration.update)
bytes differ ──► new worker installs, precaches, and WAITS
registration.waiting ≠ null and a controller exists
UpdateNotice: "A new version of Event Clock is ready." [Reload] [×]
Reload ──► postMessage {type:"skip-waiting"} ──► worker activates
──► controllerchange ──► location.reload()
```
Four properties this depends on, each of them load-bearing:
- **The worker never calls `skipWaiting()` on install.** Claiming an open page unasked leaves the
running bundle and the cached shell on two different builds, with nothing on screen saying so. It
activates only on the message the reader's tap sends. A first install has no worker to wait for and
activates immediately regardless — and is deliberately *not* announced, since nothing is being
replaced.
- **The build id is derived, not remembered.** `scripts/build-static.ts` hashes the built shell
(`index.html`, `main.js`, `styles.css`, `sw.js` source) and substitutes it for `__BUILD__` in the
worker, so any shell change alters the worker's bytes and is therefore offered. The predecessor was
a hand-bumped `CACHE_VERSION`, which had already been forgotten once. The substitution **throws**
if the placeholder is gone, because the failure mode is silent.
- **The feed is not part of the id.** It is rewritten twice a day and served network-first, so new
events reach an open page without a reload. Calling that a new version would teach readers to
dismiss the notice unread.
- **The cache name does not move with the build.** Everything in it is refetched (`cache: "reload"`,
since none of these URLs are fingerprinted) on install, so a per-build bucket would buy nothing and
would discard the stored feed — the copy an offline reader is reading.
`src/client/state/useAppUpdate.ts` holds the client half; `sw.js` and the hook cannot import each
other, so `test/update.test.tsx` pins both ends of the `skip-waiting` handshake and the placeholder.
## Deliberate non-choices
- **No ORM.** `bun:sqlite` plus hand-written SQL in `queries.ts`. The schema is six tables.
+22 -1
View File
@@ -20,7 +20,9 @@ A single-page web app that answers three questions:
- Not an account system. There is no login, no profile, no cloud sync.
- Not a wiki. It does not explain how to complete an event, only that it exists and when it ends.
- Not a notification service. No push, no email, no background alerts. (A browser-local reminder
is a plausible v2; it is out of scope for v1.)
is a plausible v2; it is out of scope for v1.) F14 is not an exception to this: it is the open page
disclosing something about *itself*, in the tab, while the reader is looking at it — nothing is
delivered anywhere, and the app is never told to wake anybody up.
- Not a damage calculator, build planner, or pull tracker.
## Users
@@ -163,6 +165,25 @@ The sources that compile these calendars, and the studios that make the games, a
screen as the data rather than one navigation step away. The page states plainly that it is
unofficial and unaffiliated, and that the source page is the authority when the two disagree.
**F14 — Say when a new version of the app is ready.**
F10 caches the shell so the app survives losing signal, and the same cache is why a reader who never
closes the tab keeps running the version they first loaded. A new game, a repaired parser or a
corrected date then reaches their device and sits there unused, with the page looking unchanged and
nothing saying why. **Presenting an old app as current is the same failure as presenting old events
as current** (F7), so it is disclosed the same way: a notice on any screen, with one action that
reloads into the new version.
It is an offer, not a swap. The app never reloads itself — doing so mid-sentence while someone types
in their own event (F13) would cost them work to save the app a tap. It says what a reload costs
(their place on the page) and what it does not (everything they have marked, typed or ticked lives in
`localStorage`, not in the bundle). Dismissing is free and the offer returns on the next load, which
is also why it need not nag.
A first install is not an update and is not announced — nothing is being replaced, and telling a
first-time reader a new version is available would be false. Neither is a feed refresh: new events
arrive without a reload, and calling that a new version would train readers to dismiss the notice
unread.
**F7 — Freshness disclosure.**
The footer shows when the feed was last updated, per game. If a game's data is more than 48 hours
stale, its lane carries a warning badge. Never present stale data as current — the whole value