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 }); +}