// BUILD is substituted per-deploy by the server with a hash of the static // assets (see serveSW in server/main.go), so the cache name — and therefore the // bytes of this file — change whenever any asset changes. That byte difference // is what makes the browser install a new worker and surface the update prompt. // Served unsubstituted (dev / a plain static host) it stays a valid constant. const BUILD = "__BUILD_HASH__"; const CACHE = `puppy-tracker-${BUILD}`; const PHOTO_CACHE = "puppy-tracker-photos-v1"; const ASSETS = [ "./", "./index.html", "./style.css", "./app.js", "./manifest.json", "./icon.svg", "./icon-180.png", "./icon-192.png", "./icon-512.png", "./changelog.json", ]; self.addEventListener("install", (event) => { // cache: "reload" bypasses the browser's HTTP cache, so a new build always // caches assets fetched fresh from the server. Without it, addAll could mix // a fresh index.html with a heuristically-cached stale app.js and install a // build whose markup references listeners the old script never registers. event.waitUntil( caches.open(CACHE).then((cache) => cache.addAll(ASSETS.map((u) => new Request(u, { cache: "reload" }))) ) ); // No skipWaiting() here: a new worker stays in "waiting" while an old one is // controlling a tab, so the page can prompt before swapping assets out from // under it. The page tells us to activate via a SKIP_WAITING message. }); self.addEventListener("message", (event) => { if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all( keys .filter((k) => k !== CACHE && k !== PHOTO_CACHE) .map((k) => caches.delete(k)) ) ) ); self.clients.claim(); }); self.addEventListener("fetch", (event) => { const req = event.request; if (req.method !== "GET") return; const url = new URL(req.url); // Photos are immutable per UUID — cache-first forever and stuff every // successful fetch into a dedicated cache so they survive between visits // and are available offline once seen. if (url.pathname.includes("/api/photos/")) { event.respondWith( caches.open(PHOTO_CACHE).then(async (cache) => { const cached = await cache.match(req); if (cached) return cached; try { const res = await fetch(req); if (res && res.ok) cache.put(req, res.clone()); return res; } catch (err) { return cached || Response.error(); } }) ); return; } // Other API calls: never cache — sync must reflect live server state. if (url.pathname.includes("/api/")) return; event.respondWith( caches.match(req).then((cached) => { if (cached) return cached; return fetch(req) .then((res) => { if (res && res.ok && url.origin === self.location.origin) { const clone = res.clone(); caches.open(CACHE).then((c) => c.put(req, clone)); } return res; }) .catch(() => caches.match("./index.html")); }) ); }); // ---------- push reminders ---------- // The server evaluates reminder rules and pushes the ones that come due (see // server/reminders.go). Subscriptions are userVisibleOnly, so every push must // result in a notification — there is no silent path to fall back on. self.addEventListener("push", (event) => { let data = {}; try { data = event.data ? event.data.json() : {}; } catch (err) { data = {}; } // The tag is what makes a repeat of the same reminder replace the previous // notification instead of stacking another one on the lock screen, and // renotify:false lets that replacement happen without re-alerting. event.waitUntil( self.registration.showNotification(data.title || "Puppy Tracker", { body: data.body || "", tag: data.tag || "reminder", renotify: false, icon: "./icon-192.png", badge: "./icon-192.png", data: { url: data.url || "./" }, }) ); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); const url = (event.notification.data && event.notification.data.url) || "./"; // Prefer focusing a tab the app is already open in — opening a second window // onto the same PWA is disorienting and loses whatever was on screen. event.waitUntil( self.clients .matchAll({ type: "window", includeUncontrolled: true }) .then((clients) => { for (const client of clients) { if ("focus" in client) return client.focus(); } return self.clients.openWindow(url); }) ); });