From 089d77aa68cdac3a40ac9fc754d32de74d3eeade Mon Sep 17 00:00:00 2001 From: RouteComm Dev Date: Fri, 3 Jul 2026 18:48:39 -0600 Subject: [PATCH] =?UTF-8?q?feat(water-log):=20cycle=206=20=E2=80=94=20work?= =?UTF-8?q?er=20sub-PWA=20at=20/water=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- public/manifest-field.json | 17 +++ public/sw-field.js | 129 ++++++++++++++++ src/app/water/layout.tsx | 58 +++++++ src/components/pwa/FieldInstallPrompt.tsx | 168 +++++++++++++++++++++ src/components/pwa/FieldSWRegistration.tsx | 43 ++++++ 5 files changed, 415 insertions(+) create mode 100644 public/manifest-field.json create mode 100644 public/sw-field.js create mode 100644 src/app/water/layout.tsx create mode 100644 src/components/pwa/FieldInstallPrompt.tsx create mode 100644 src/components/pwa/FieldSWRegistration.tsx diff --git a/public/manifest-field.json b/public/manifest-field.json new file mode 100644 index 0000000..3d6743c --- /dev/null +++ b/public/manifest-field.json @@ -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"] +} diff --git a/public/sw-field.js b/public/sw-field.js new file mode 100644 index 0000000..7e99640 --- /dev/null +++ b/public/sw-field.js @@ -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))); +}); diff --git a/src/app/water/layout.tsx b/src/app/water/layout.tsx new file mode 100644 index 0000000..fa8d6b7 --- /dev/null +++ b/src/app/water/layout.tsx @@ -0,0 +1,58 @@ +/** + * Cycle 6 — Field-worker sub-PWA layout. Overrides the root layout's + * `manifest`, `themeColor`, and `applicationName` so the worker PWA + * installs as "Water Log — Tuxedo" rather than "Route Commerce". + * + * Per Next.js App Router semantics, child layouts can override any + * `Metadata` field set by an ancestor — `manifest` and `themeColor` + * both qualify. The root layout still provides fonts + providers. + */ +import type { Metadata, Viewport } from "next"; +import { FieldSWRegistration } from "@/components/pwa/FieldSWRegistration"; +import { FieldInstallPrompt } from "@/components/pwa/FieldInstallPrompt"; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + themeColor: "#14532d", + viewportFit: "cover", +}; + +export const metadata: Metadata = { + title: { + default: "Water Log · Tuxedo", + template: "%s · Water Log", + }, + description: + "Field log of Tuxedo water readings + worker time tracking — PIN-protected, mobile-first.", + applicationName: "Water Log", + manifest: "/manifest-field.json", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Water Log", + }, + robots: { + index: false, + follow: false, + }, +}; + +export default function WaterLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + {/* Client-side: register the scoped SW + show the field- + branded install prompt. Both are no-op on first paint and + only fire under user-instigated conditions. */} + + + {children} + + ); +} diff --git a/src/components/pwa/FieldInstallPrompt.tsx b/src/components/pwa/FieldInstallPrompt.tsx new file mode 100644 index 0000000..a274bdc --- /dev/null +++ b/src/components/pwa/FieldInstallPrompt.tsx @@ -0,0 +1,168 @@ +/** + * FieldInstallPrompt — install prompt for the worker sub-PWA. + * + * Cycle 6 sibling of `InstallPrompt` (mounted from the root layout). + * This one fires only on /water routes and uses the field-PWA + * branding (forest green icon, "Water Log — Tuxedo") so the prompt + * that surfaces matches the manifest that will be installed. + * + * Same `BeforeInstallPromptEvent` capture pattern as InstallPrompt; + * split into a separate component file because the worker surface + * has its own theming + copy. + */ +"use client"; + +import { useEffect, useSyncExternalStore, useCallback, useReducer } from "react"; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +type PromptState = { + deferred: BeforeInstallPromptEvent | null; + visible: boolean; +}; + +type PromptAction = + | { type: "captured"; event: BeforeInstallPromptEvent } + | { type: "show" } + | { type: "hide" } + | { type: "dismissed" } + | { type: "reset" }; + +function promptReducer(state: PromptState, action: PromptAction): PromptState { + switch (action.type) { + case "captured": + return { deferred: action.event, visible: false }; + case "show": + return state.deferred ? { ...state, visible: true } : state; + case "hide": + return { deferred: null, visible: false }; + case "dismissed": + return { ...state, visible: false }; + case "reset": + return { deferred: null, visible: false }; + } +} + +const INITIAL_PROMPT_STATE: PromptState = { deferred: null, visible: false }; + +export function FieldInstallPrompt() { + const [prompt, dispatch] = useReducer(promptReducer, INITIAL_PROMPT_STATE); + const isInstalled = useSyncExternalStore( + () => () => {}, + () => + typeof window !== "undefined" && + (window.matchMedia("(display-mode: standalone)").matches || + // iOS Safari uses navigator.standalone for the home-screen PWA + // launch. Cover both. + (typeof (navigator as Navigator & { standalone?: boolean }).standalone === + "boolean" && + (navigator as Navigator & { standalone?: boolean }).standalone === true)), + () => false, + ); + const mounted = useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); + + useEffect(() => { + if (isInstalled) return; + if (typeof window === "undefined") return; + + const handleBeforeInstall = (e: Event) => { + e.preventDefault(); + dispatch({ type: "captured", event: e as BeforeInstallPromptEvent }); + // Show the field prompt sooner than the main one — workers + // know they want this installed on day one. + window.setTimeout(() => { + dispatch({ type: "show" }); + }, 4000); + }; + + const handleAppInstalled = () => { + dispatch({ type: "reset" }); + }; + + window.addEventListener("beforeinstallprompt", handleBeforeInstall); + window.addEventListener("appinstalled", handleAppInstalled); + + return () => { + window.removeEventListener("beforeinstallprompt", handleBeforeInstall); + window.removeEventListener("appinstalled", handleAppInstalled); + }; + }, [isInstalled]); + + const handleInstall = useCallback(async () => { + const deferred = prompt.deferred; + if (!deferred) return; + + await deferred.prompt(); + const { outcome } = await deferred.userChoice; + void outcome; + dispatch({ type: "reset" }); + }, [prompt.deferred]); + + const handleDismiss = useCallback(() => { + dispatch({ type: "dismissed" }); + try { + sessionStorage.setItem("field-pwa-install-dismissed", "true"); + } catch { + // ignore + } + }, []); + + if (!mounted || !prompt.visible || isInstalled || !prompt.deferred) { + return null; + } + + return ( +
+
+
+ + + +
+
+

+ Install Water Log +

+

+ One tap to a full-screen PIN entry from your home screen. +

+
+
+
+ + +
+
+ ); +} + +export default FieldInstallPrompt; diff --git a/src/components/pwa/FieldSWRegistration.tsx b/src/components/pwa/FieldSWRegistration.tsx new file mode 100644 index 0000000..5518a92 --- /dev/null +++ b/src/components/pwa/FieldSWRegistration.tsx @@ -0,0 +1,43 @@ +/** + * FieldSWRegistration — registers /sw-field.js with scope=/water. + * + * Cycle 6 component used by the worker sub-PWA. The component is + * mounted from src/app/water/layout.tsx (a thin client boundary + * inside the otherwise-server layout) so the registration only + * happens on /water routes — never on /admin, /tuxedo, etc. + * + * No-ops on: + * - Server (guards with typeof navigator) + * - repeat mounts (uses a module-scoped flag so React strict-mode + * double-mount doesn't double-register) + * + * Same registration pattern as next-pwa — we use the raw + * ServiceWorkerContainer API to stay framework-free. + */ +"use client"; + +import { useEffect } from "react"; + +let registered = false; + +export function FieldSWRegistration() { + useEffect(() => { + if (typeof navigator === "undefined") return; + if (!("serviceWorker" in navigator)) return; + if (registered) return; + registered = true; + void navigator.serviceWorker + .register("/sw-field.js", { scope: "/water" }) + .catch((err) => { + // Surface to console for ops; never block the UI. The + // app works without the SW — it just loses offline shell + // caching for /water. + console.error("[field-sw] registration failed", err); + // Allow a retry on a later mount. + registered = false; + }); + }, []); + return null; +} + +export default FieldSWRegistration;