feat: work offline

The reader's question is answered entirely by data already on the device and
countdowns run off the local clock, so losing signal should not lose the app.

The shell is cache-first; the feed is network-first falling back to the last
copy seen, because stale events beat a blank screen. Webfonts are cached too
— without them an offline load silently drops to system faces and the whole
thing changes character.

Offline is surfaced in the header and above the footer rather than hidden:
stale data must never be presented as current.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:52:41 +02:00
co-authored by Claude Opus 5
parent 1a04e9472b
commit c633d0510d
6 changed files with 169 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Event Clock">
<rect width="64" height="64" rx="14" fill="#12141C"/>
<g>
<rect x="10" y="26" width="5" height="12" rx="1.5" fill="#FF4D6A"/>
<rect x="17" y="26" width="5" height="12" rx="1.5" fill="#FF4D6A"/>
<rect x="24" y="26" width="5" height="12" rx="1.5" fill="#E8B33C"/>
<rect x="31" y="26" width="5" height="12" rx="1.5" fill="#E8B33C"/>
<rect x="38" y="26" width="5" height="12" rx="1.5" fill="#2C3145"/>
<rect x="45" y="26" width="5" height="12" rx="1.5" fill="#2C3145"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 605 B

+11
View File
@@ -10,3 +10,14 @@ createRoot(root).render(
<App />
</StrictMode>,
);
// Offline support. Registered after render so it never delays first paint, and
// guarded because file:// and older browsers have no service worker at all.
if ("serviceWorker" in navigator && location.protocol.startsWith("http")) {
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch(() => {
// An unavailable worker costs offline support, nothing else. The app
// works exactly as before.
});
});
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Event Clock",
"short_name": "Event Clock",
"description": "Live and upcoming gacha events, sorted by what expires next.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#12141c",
"theme_color": "#12141c",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+123
View File
@@ -0,0 +1,123 @@
/*
* Service worker: keep the app usable without a network.
*
* This app is a good offline candidate — the reader's question ("what expires
* next?") is answered entirely by data already on the device, and countdowns
* tick from the local clock. Losing signal on a train should not lose the app.
*
* Two strategies, chosen per resource:
*
* shell (html/css/js) cache-first — it changes only on deploy
* feed (events.json) network-first with cache fallback — fresher is better,
* but stale events beat a blank screen
*
* Bump CACHE_VERSION on any shell change; old caches are deleted on activate.
*/
const CACHE_VERSION = "event-clock-v1";
const SHELL = ["/", "/index.html", "/styles.css", "/main.js"];
const FEED = "/data/events.v1.json";
const FONT_HOSTS = new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_VERSION)
// addAll is atomic — one 404 would leave nothing cached, so failures are
// tolerated per-item and the fetch handler fills gaps later.
.then((cache) => Promise.allSettled(SHELL.map((url) => cache.add(url))))
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
// Webfonts are cross-origin but part of the shell: without them an offline
// load silently falls back to system faces and the whole thing changes
// character. Opaque responses cache fine for this purpose.
if (FONT_HOSTS.has(url.host)) {
event.respondWith(shellFirst(request));
return;
}
if (url.origin !== self.location.origin) return;
if (url.pathname === FEED) {
event.respondWith(feedFirst(request));
return;
}
event.respondWith(shellFirst(request));
});
/**
* Network first. A successful response is cached so the next offline load has
* the freshest events we ever saw; a failure falls back to that copy.
*/
async function feedFirst(request) {
const cache = await caches.open(CACHE_VERSION);
try {
const response = await fetch(request);
if (response.ok) await cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached !== undefined) return cached;
// No network and nothing cached: say so in the feed's own shape, so the
// client renders its error state rather than failing to parse.
return new Response(
JSON.stringify({ error: "offline", message: "No events stored yet." }),
{ status: 503, headers: { "content-type": "application/json" } },
);
}
}
/**
* Cache first, revalidating in the background so a deploy is picked up on the
* next visit without ever blocking this one.
*/
async function shellFirst(request) {
const cache = await caches.open(CACHE_VERSION);
const cached = await cache.match(request, { ignoreSearch: true });
const network = fetch(request)
.then((response) => {
// Opaque cross-origin font responses report ok === false but are still
// worth storing — they render fine from cache.
if (response.ok || response.type === "opaque") {
void cache.put(request, response.clone());
}
return response;
})
.catch(() => undefined);
if (cached !== undefined) return cached;
const response = await network;
if (response !== undefined) return response;
// A navigation with no cache and no network still gets the app shell if we
// have it — the client then shows its own offline message.
if (request.mode === "navigate") {
const shell = await cache.match("/index.html");
if (shell !== undefined) return shell;
}
return new Response("Offline", { status: 503 });
}