From c633d0510d1220d15ca046112be595d2f3568c06 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 00:52:41 +0200 Subject: [PATCH] feat: work offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- index.html | 3 + package.json | 4 +- src/client/icon.svg | 11 +++ src/client/main.tsx | 11 +++ src/client/manifest.webmanifest | 19 +++++ src/client/sw.js | 123 ++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/client/icon.svg create mode 100644 src/client/manifest.webmanifest create mode 100644 src/client/sw.js diff --git a/index.html b/index.html index ad82832..7cc7d69 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,9 @@ href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=Public+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet" /> + + + diff --git a/package.json b/package.json index e283b4d..55cec71 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "build:feed": "bun run scripts/build-feed.ts", "build:css": "bunx @tailwindcss/cli -i src/client/styles.css -o public/styles.css --minify", "build:js": "bun build src/client/main.tsx --outfile public/main.js --minify", - "build": "bun run build:feed && bun run build:css && bun run build:js && bun run build:html", + "build": "bun run build:feed && bun run build:css && bun run build:js && bun run build:static", "dev": "bun run build && bunx serve public -l 3000", - "build:html": "cp index.html public/index.html" + "build:static": "cp index.html public/index.html && cp src/client/sw.js public/sw.js && cp src/client/manifest.webmanifest public/manifest.webmanifest && cp src/client/icon.svg public/icon.svg" }, "dependencies": { "react": "^19.2.8", diff --git a/src/client/icon.svg b/src/client/icon.svg new file mode 100644 index 0000000..ca5003b --- /dev/null +++ b/src/client/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/client/main.tsx b/src/client/main.tsx index bf619ac..e3b48cc 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -10,3 +10,14 @@ createRoot(root).render( , ); + +// 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. + }); + }); +} diff --git a/src/client/manifest.webmanifest b/src/client/manifest.webmanifest new file mode 100644 index 0000000..7873e30 --- /dev/null +++ b/src/client/manifest.webmanifest @@ -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" + } + ] +} diff --git a/src/client/sw.js b/src/client/sw.js new file mode 100644 index 0000000..bd83a17 --- /dev/null +++ b/src/client/sw.js @@ -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 }); +}