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)));
|
||||
});
|
||||
@@ -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. */}
|
||||
<FieldSWRegistration />
|
||||
<FieldInstallPrompt />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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 (
|
||||
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-white rounded-2xl shadow-2xl border border-[#1a4d2e]/30 p-4 z-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 bg-[#1a4d2e] rounded-xl flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-[#1a4d2e]">
|
||||
Install Water Log
|
||||
</h3>
|
||||
<p className="text-sm text-[rgba(60,60,67,0.65)] mt-1">
|
||||
One tap to a full-screen PIN entry from your home screen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-[rgba(60,60,67,0.55)] hover:text-[rgba(60,60,67,0.85)] transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-4 bg-[#1a4d2e] text-white rounded-lg text-sm font-medium hover:bg-[#14432a] py-2 transition-colors"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldInstallPrompt;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user