Files
recurring-checklist/server/public/sw.js
T
Waters ChurchandClaude Opus 5 469db5bc60 Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:00:57 -04:00

71 lines
3.4 KiB
JavaScript

// Service worker.
// Goal: keep the app usable on flaky/no network without ever serving stale DATA.
// • App SHELL (HTML/CSS/JS/manifest) — network-first, fall back to cache so the
// app still loads when offline. Successful fetches refresh the cache.
// • API (/api/*) — never cached. The client handles offline itself (it queues
// check-offs in localStorage and syncs on reconnect).
const SW_VERSION = 'v5';
const SHELL_CACHE = 'shell-' + SW_VERSION;
const SHELL = [
'/', '/index.html', '/app.js', '/styles.css', '/md.js', '/manifest.webmanifest',
'/vendor/motion.global.js'
];
self.addEventListener('install', (event) => {
event.waitUntil(
// Per-URL (not atomic addAll): one missing/404 entry must not reject the whole batch and
// leave the new shell cache empty — otherwise a hard-offline navigation right after a
// version bump would fall through to the offline page. allSettled caches what it can.
caches.open(SHELL_CACHE)
.then((c) => Promise.allSettled(SHELL.map((u) => c.add(u))))
.catch(() => {})
);
self.skipWaiting(); // activate the new worker on next load
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
const OFFLINE_HTML =
'<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Offline</title><style>body{font-family:-apple-system,Segoe UI,sans-serif;background:#1a1a1a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;padding:20px;}h1{color:#3498db;margin-bottom:8px;}p{color:#b0b0b0;max-width:380px;}</style></head><body><div><h1>Offline</h1><p>The checklist needs an internet connection the first time. Try again when you\'re back online.</p></div></body></html>';
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // third-party: passthrough
if (url.pathname.startsWith('/api/')) return; // never cache API; client handles offline
// Full-resolution equipment photos can be several MB each and pile up fast; caching them
// in the shell risks blowing the (small, all-or-nothing) iOS cache quota and evicting the
// app shell. They already carry an immutable 1-year HTTP cache header, so let the browser's
// HTTP cache handle them and keep them out of the shell cache entirely.
if (url.pathname.startsWith('/equip-images/')) return;
event.respondWith((async () => {
try {
const fresh = await fetch(req);
// Cache successful shell/static GETs for offline fallback.
if (fresh && fresh.ok && fresh.type === 'basic') {
const cache = await caches.open(SHELL_CACHE);
cache.put(req, fresh.clone());
}
return fresh;
} catch (_) {
const cached = await caches.match(req);
if (cached) return cached;
if (req.mode === 'navigate') {
const idx = (await caches.match('/index.html')) || (await caches.match('/'));
if (idx) return idx;
return new Response(OFFLINE_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
throw new Error('Offline');
}
})());
});