// Service worker — internal filename/comment only, never shown to a user. // // Scope, deliberately: this app is almost entirely dynamic, signed-in, // per-request server-rendered content (appointments, calendars, loyalty // balances) — caching that would risk serving stale or, worse, another // user's data. So this file does NOT try to make the booking flow work // offline. What it does do: // - Push notifications (`push`/`notificationclick`), plus an app-icon // badge number counting the ones still unread (see updateAppBadge). // - Precache + serve a small static app shell (offline fallback page, // manifest, icons) so a lost connection shows a real "you're offline" // screen instead of the browser's own dinosaur/error page. // - An update-available handshake: a new SW version installs and waits // rather than taking over immediately, so the running page can prompt // the user before switching (see update-available-banner.tsx). // // Every GET request that isn't the app shell or a navigation falls // through untouched (`fetch` handler doesn't call respondWith), and every // non-GET request (Server Actions, API mutations) is never intercepted at // all — this worker must never sit between the app and a write. // v2: /manifest.webmanifest was previously cache-first here alongside the // truly static icon fallbacks — but it's dynamic, per-business content // (name/icons/colors, editable from "App Icon & Home Screen" settings), // not a static build asset. Cache-first meant a phone that had ever // loaded it once would keep serving that stale response indefinitely, // regardless of any later branding change or server-side fix — bumping // CACHE_VERSION forces every existing install to drop its old cache // (including any previously-cached manifest response) on next activate. const CACHE_VERSION = "v2"; const CACHE_NAME = `nextchair-shell-${CACHE_VERSION}`; const OFFLINE_URL = "/offline"; const APP_SHELL = [OFFLINE_URL, "/icons/icon-192.png", "/icons/icon-512.png"]; self.addEventListener("install", (event) => { event.waitUntil( caches .open(CACHE_NAME) .then((cache) => cache.addAll(APP_SHELL)) // Deliberately no self.skipWaiting() here — an already-open tab keeps // running its current version until the user acts on the // update-available prompt (see the `message` handler below). .catch(() => { // A failed precache (e.g. first install with no network at all) // shouldn't block installation — push notifications should still // work even if the offline shell couldn't be cached yet. }), ); }); self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) .then(() => self.clients.claim()), ); }); self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") return; const url = new URL(request.url); if (url.origin !== self.location.origin) return; // Page navigations: network-first (this app is dynamic — a cached page // would be stale or session-mismatched), falling back to the offline // shell only when the network is actually unreachable. if (request.mode === "navigate") { event.respondWith( fetch(request).catch(() => caches.match(OFFLINE_URL).then((cached) => cached ?? Response.error())), ); return; } // The static app-shell assets themselves: cache-first, since these are // versioned by CACHE_NAME and only change on a new SW deploy. if (APP_SHELL.some((path) => url.pathname === path)) { event.respondWith( caches.match(request).then((cached) => cached ?? fetch(request)), ); return; } // Everything else (Next static build assets, API routes, images) — // untouched, normal network fetch. }); self.addEventListener("message", (event) => { if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); }); /** * App-icon badge = how many of this app's push notifications are still * sitting in the notification shelf. Counting what the OS is actually * showing (instead of a stored counter) means nothing needs persisting — * a service worker can be stopped at any moment, losing in-memory state. * The page clears the badge and the shelf when the app is opened (see * service-worker-manager.tsx). * * Needs the Badging API: installed PWAs on Android/desktop Chrome and * Home Screen web apps on iOS 16.4+. Everywhere else this is a no-op. */ async function updateAppBadge() { if (!("setAppBadge" in self.navigator)) return; try { const count = (await self.registration.getNotifications()).length; if (count > 0) await self.navigator.setAppBadge(count); else await self.navigator.clearAppBadge(); } catch { // Badging is cosmetic — never let it break showing the notification. } } /** True when the app is open and in front of the person — a badge on the icon is pointless (and stale the moment they leave) then. */ async function appIsInForeground() { const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); return windows.some((client) => client.visibilityState === "visible"); } self.addEventListener("push", (event) => { if (!event.data) return; let payload; try { payload = event.data.json(); } catch { return; } // The server always sets a real, business-branded title (see // src/services/notifications/reminder-worker.ts) — this is only a // defensive fallback for a malformed payload, so it stays neutral // rather than naming the internal product. const title = payload.title || "New notification"; const options = { body: payload.body || "", data: { url: payload.url || "/" }, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png", }; event.waitUntil( self.registration .showNotification(title, options) .then(async () => { if (!(await appIsInForeground())) await updateAppBadge(); }), ); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); const url = event.notification.data?.url || "/"; event.waitUntil( Promise.all([ updateAppBadge(), self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { for (const client of clientList) { if (client.url.includes(url) && "focus" in client) return client.focus(); } if (self.clients.openWindow) return self.clients.openWindow(url); }), ]), ); });