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:
co-authored by
Claude Opus 5
parent
1a04e9472b
commit
c633d0510d
@@ -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"
|
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"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+2
-2
@@ -9,9 +9,9 @@
|
|||||||
"build:feed": "bun run scripts/build-feed.ts",
|
"build:feed": "bun run scripts/build-feed.ts",
|
||||||
"build:css": "bunx @tailwindcss/cli -i src/client/styles.css -o public/styles.css --minify",
|
"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: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",
|
"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": {
|
"dependencies": {
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
|||||||
@@ -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 |
@@ -10,3 +10,14 @@ createRoot(root).render(
|
|||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</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.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user