feat(water-log): cycle 6 — worker sub-PWA at /water scope
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).
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Water Log — Tuxedo",
|
||||
"short_name": "Water Log",
|
||||
"description": "Field access for Tuxedo water + time tracking",
|
||||
"start_url": "/water",
|
||||
"scope": "/water",
|
||||
"display": "standalone",
|
||||
"background_color": "#f2f2f7",
|
||||
"theme_color": "#14532d",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"categories": ["productivity", "utilities"]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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)));
|
||||
});
|
||||
Reference in New Issue
Block a user