089d77aa68
Same-origin sub-PWA for Tuxedo field workers, fully isolated from the main Route Commerce PWA. - public/manifest-field.json: Tuxedo-branded manifest, start_url=/water, scope=/water, themeColor=#14532d, no Route Commerce branding - public/sw-field.js: scoped SW with field-shell-v1 + field-data-v1 caches (never touches main app's rc-shell-v* / rc-data-v*). Skips any request outside /water scope. Two-cache strategy: shell cache-first + /api/water stale-while-revalidate. - src/app/water/layout.tsx: child layout overrides root metadata (manifest, themeColor, applicationName, appleWebApp.title) and mounts the field SW registration + install prompt. - src/components/pwa/FieldSWRegistration.tsx: registers /sw-field.js with scope=/water. Module-scoped registered flag prevents strict- mode double-registration. - src/components/pwa/FieldInstallPrompt.tsx: sibling of InstallPrompt, forest-green #1a4d2e theming, fires after 4s dwell. Captures display-mode:standalone AND navigator.standalone for iOS. No conflict with the main app PWA: PWAInstallPrompt is mounted only under /admin/layout.tsx, so /water routes only see FieldInstallPrompt. Tuxedo-only by design (matches existing /water single-tenant hardcode).
130 lines
3.8 KiB
JavaScript
130 lines
3.8 KiB
JavaScript
// public/sw-field.js — Tuxedo field-worker service worker.
|
|
//
|
|
// Cycle 6 sub-PWA, scoped to /water. Tighter cache strategy than
|
|
// the main shell SW because:
|
|
// - The field app is a thin surface (PIN, headgate list, log form,
|
|
// time tab). Few async chunks; mostly static + the latest data
|
|
// snapshot.
|
|
// - "Offline" here means "still see the cached PIN screen + headgate
|
|
// list" — water entry without connectivity is a degraded but
|
|
// non-blocking experience (the form queue handles drafts).
|
|
//
|
|
// Cache names are deliberately prefixed with `field-` so they never
|
|
// collide with the main `rc-shell-v*` / `rc-data-v*` caches.
|
|
|
|
const SHELL_CACHE = "field-shell-v1";
|
|
const DATA_CACHE = "field-data-v1";
|
|
const OFFLINE_URL = "/offline.html";
|
|
|
|
const SHELL_ASSETS = [
|
|
"/water",
|
|
"/manifest-field.json",
|
|
"/favicon.svg",
|
|
"/og-default.svg",
|
|
"/offline.html",
|
|
"/icons/icon-192x192.png",
|
|
"/icons/icon-512x512.png",
|
|
];
|
|
|
|
self.addEventListener("install", (event) => {
|
|
event.waitUntil(
|
|
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
|
|
);
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
// Only manage field-* caches — never touch the main app's caches.
|
|
event.waitUntil(
|
|
Promise.all([
|
|
caches.keys().then((cacheNames) => {
|
|
const toDelete = [];
|
|
for (const name of cacheNames) {
|
|
if (
|
|
name !== SHELL_CACHE &&
|
|
name !== DATA_CACHE &&
|
|
name.startsWith("field-")
|
|
) {
|
|
toDelete.push(caches.delete(name));
|
|
}
|
|
}
|
|
return Promise.all(toDelete);
|
|
}),
|
|
self.clients.claim(),
|
|
])
|
|
);
|
|
});
|
|
|
|
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;
|
|
|
|
// Out-of-scope requests: skip (let the main app SW handle them
|
|
// if it's installed for that scope; fall through to network).
|
|
if (!url.pathname.startsWith("/water")) return;
|
|
|
|
// Navigations → network-first, fall back to cache, fall back to offline page.
|
|
if (req.mode === "navigate") {
|
|
event.respondWith(
|
|
fetch(req)
|
|
.then((res) => {
|
|
const clone = res.clone();
|
|
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
|
return res;
|
|
})
|
|
.catch(() =>
|
|
caches.match(req).then(
|
|
(cached) => cached ?? caches.match(OFFLINE_URL),
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Static assets → cache-first
|
|
if (
|
|
url.pathname.startsWith("/_next/static/") ||
|
|
url.pathname.startsWith("/icons/") ||
|
|
url.pathname.endsWith(".svg") ||
|
|
url.pathname.endsWith(".woff2")
|
|
) {
|
|
event.respondWith(
|
|
caches.match(req).then((cached) => {
|
|
if (cached) return cached;
|
|
return fetch(req).then((res) => {
|
|
const clone = res.clone();
|
|
if (res.status === 200) {
|
|
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
|
}
|
|
return res;
|
|
});
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// /api/water* GETs → stale-while-revalidate (so the headgate list
|
|
// and worker availability can hydrate from cache while we re-fetch).
|
|
if (url.pathname.startsWith("/api/water")) {
|
|
event.respondWith(
|
|
caches.open(DATA_CACHE).then((cache) =>
|
|
cache.match(req).then((cached) => {
|
|
const network = fetch(req)
|
|
.then((res) => {
|
|
if (res.status === 200) cache.put(req, res.clone());
|
|
return res;
|
|
})
|
|
.catch(() => cached);
|
|
return cached ?? network;
|
|
}),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Default: try network, fall back to cache.
|
|
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
|
});
|