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