diff --git a/STATUS.md b/STATUS.md index 3e17e7d..715db8a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -28,6 +28,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **31 — Public share viewer**. Made `actorId` nullable in `activity_log` (migration `0010_nullable_actor_id.sql`, `onDelete: "set null"`) for anonymous share-page mutations. Added `logShareActivity` to `_core/activity.ts` (no session, explicit `householdId`). Added `householdId` to `resolveShareToken` return. Added `renderSharedView` to `EntityTypeRegistration` type. Each module implements `loadForShare` (bare DB queries, no session) and `renderSharedView`: calendar shows upcoming 90-day events or single-event details, lists shows items with optional toggle, notes shows title + body. `toggleShareListItem` server action lives in `lists/server/share-actions.ts` — validates token write capability, verifies item→list→household chain, logs `share.toggle` with `actorId = null`. `/app/s/[token]/page.tsx` resolves token, dispatches to `loadForShare` + `renderSharedView`, returns friendly error for invalid/expired tokens, sets `noindex`. Middleware `/s/*` exemption confirmed present. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. - **50 — PWA shell**. `public/manifest.webmanifest` (name, short_name, icons, theme_color, display: standalone, start_url `/`). Placeholder PNG icons at 180, 192, 384, 512 (regular + maskable) generated by `scripts/generate-icons.mjs` (`pnpm gen:icons`); `public/icon.svg` committed as source. Hand-rolled service worker at `public/sw.js`: precaches `offline.html` on install, cache-first for `/_next/static/`, network-first for navigation with offline fallback, network-only for API routes. `src/components/pwa-register.tsx` registers the SW client-side. `src/components/install-prompt.tsx` shows a dismissible banner: `beforeinstallprompt` on Android/Chrome, a one-time "Add to Home Screen" hint on iOS (detected via UA + `navigator.maxTouchPoints`, suppressed in standalone mode). Root layout exports `viewport` (themeColor), updated `metadata` (manifest, appleWebApp, apple-touch-icon), and mounts both new components. `pnpm typecheck`, `pnpm lint`, `pnpm build` all clean. +- **51 — Offline shell + service worker caching**. `next.config.ts` generates `public/sw.js` as a side effect on every `next build` / `next dev` invocation, embedding a build timestamp as `CACHE_VERSION` (stable `"dev"` string in development to avoid hot-reload cache churn; epoch milliseconds in production). SW strategies: stale-while-revalidate for `/_next/static/` chunks and navigation HTML (cached page served instantly, network update fires in background); network-first with 2-second abort timeout for API GETs falling back to cache; network-only for mutations (POST/PATCH/DELETE/PUT) — if offline, all controlled clients receive `{ type: "OFFLINE_MUTATION" }` via `postMessage` and a synthetic 503 is returned. Activate handler evicts all `famapp-*` caches whose suffix doesn't match the current version, then claims clients. `pwa-register.tsx` extended with three inline toasts: amber "offline" banner (persistent, driven by `navigator.onLine` + `online`/`offline` events), red "changes can't be saved" toast (auto-dismisses in 4 s, driven by SW postMessage), and indigo "new version available — refresh" bottom toast (driven by `controllerchange` with `hadController` guard). `pnpm typecheck`, `pnpm build` pass. ## Next up diff --git a/next.config.ts b/next.config.ts index a8b7383..7a5fbd1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,146 @@ import type { NextConfig } from "next"; +import { writeFileSync } from "fs"; +import { resolve } from "path"; + +// Stable in dev to avoid cache churn on hot-reload; unique per production build. +const BUILD_TIME = + process.env.NODE_ENV === "development" ? "dev" : String(Date.now()); + +writeFileSync(resolve(process.cwd(), "public/sw.js"), buildSwContent(BUILD_TIME)); + +function buildSwContent(version: string): string { + return `// famapp service worker — v${version} +// Generated at build time. Do not edit directly. +const CACHE_VERSION = "${version}"; +const SHELL = "famapp-shell-" + CACHE_VERSION; +const API = "famapp-api-" + CACHE_VERSION; +const OFFLINE = "/offline.html"; + +// --- install: precache the offline fallback --- + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(SHELL).then((c) => c.add(OFFLINE))); + self.skipWaiting(); +}); + +// --- activate: evict old-version caches, claim clients --- + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter( + (k) => + k.startsWith("famapp-") && !k.endsWith("-" + CACHE_VERSION) + ) + .map((k) => caches.delete(k)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +// --- fetch: strategy dispatch --- + +self.addEventListener("fetch", (e) => { + const { request } = e; + const url = new URL(request.url); + + // Mutations — network-only; notify clients if the network is unreachable. + if (request.method !== "GET") { + e.respondWith( + fetch(request).catch(() => { + self.clients + .matchAll({ includeUncontrolled: true }) + .then((cs) => + cs.forEach((c) => c.postMessage({ type: "OFFLINE_MUTATION" })) + ); + return new Response(JSON.stringify({ error: "offline" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + }) + ); + return; + } + + // Auth endpoints — always network-only. + if (url.pathname.startsWith("/api/auth/")) return; + + // Next.js immutable static chunks — stale-while-revalidate. + if (url.pathname.startsWith("/_next/static/")) { + e.respondWith(staleWhileRevalidate(request, SHELL)); + return; + } + + // API GETs — network-first with 2-second timeout, fall back to cache. + if (url.pathname.startsWith("/api/")) { + e.respondWith(networkFirstWithTimeout(request, API, 2000)); + return; + } + + // Navigation — stale-while-revalidate; fall back to offline page. + if (request.mode === "navigate") { + e.respondWith(navigateWithFallback(request)); + return; + } +}); + +// --- strategy helpers --- + +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + const update = fetch(request) + .then((res) => { + if (res.ok) cache.put(request, res.clone()); + return res; + }) + .catch(() => null); + // Serve cached immediately; let update happen in the background. + return cached ?? (await update); +} + +async function networkFirstWithTimeout(request, cacheName, ms) { + const cache = await caches.open(cacheName); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), ms); + try { + const res = await fetch(request, { signal: ac.signal }); + clearTimeout(timer); + if (res.ok) cache.put(request, res.clone()); + return res; + } catch { + clearTimeout(timer); + return (await cache.match(request)) ?? new Response(null, { status: 503 }); + } +} + +async function navigateWithFallback(request) { + const cache = await caches.open(SHELL); + const cached = await cache.match(request); + const networkFetch = fetch(request) + .then((res) => { + if (res.ok) cache.put(request, res.clone()); + return res; + }) + .catch(() => null); + if (cached) { + // Return cached immediately; revalidate in the background. + networkFetch; + return cached; + } + const net = await networkFetch; + if (net) return net; + return ( + (await cache.match(OFFLINE)) ?? new Response("Offline", { status: 503 }) + ); +} +`; +} const nextConfig: NextConfig = { reactStrictMode: true, diff --git a/public/sw.js b/public/sw.js index f50512a..7a1faf6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,69 +1,130 @@ -// famapp service worker — app-shell pattern -// Caches a minimal offline fallback on install. -// Navigation requests: network-first with offline fallback. -// Next.js static chunks: cache-first. -// API routes: network-only (pass through). - -const CACHE = "famapp-shell-v1"; +// famapp service worker — v1778101829038 +// Generated at build time. Do not edit directly. +const CACHE_VERSION = "1778101829038"; +const SHELL = "famapp-shell-" + CACHE_VERSION; +const API = "famapp-api-" + CACHE_VERSION; const OFFLINE = "/offline.html"; -// --- install: precache offline shell --- +// --- install: precache the offline fallback --- -self.addEventListener("install", (event) => { - event.waitUntil( - caches.open(CACHE).then((cache) => cache.add(OFFLINE)) - ); +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(SHELL).then((c) => c.add(OFFLINE))); self.skipWaiting(); }); -// --- activate: evict old caches --- +// --- activate: evict old-version caches, claim clients --- -self.addEventListener("activate", (event) => { - event.waitUntil( +self.addEventListener("activate", (e) => { + e.waitUntil( caches .keys() .then((keys) => - Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + Promise.all( + keys + .filter( + (k) => + k.startsWith("famapp-") && !k.endsWith("-" + CACHE_VERSION) + ) + .map((k) => caches.delete(k)) + ) ) + .then(() => self.clients.claim()) ); - self.clients.claim(); }); -// --- fetch --- - -self.addEventListener("fetch", (event) => { - const { request } = event; - if (request.method !== "GET") return; +// --- fetch: strategy dispatch --- +self.addEventListener("fetch", (e) => { + const { request } = e; const url = new URL(request.url); - // API routes — always go to network, never cache. - if (url.pathname.startsWith("/api/")) return; - - // NextAuth — network-only. - if (url.pathname.startsWith("/api/auth/")) return; - - // Next.js immutable static assets — cache-first. - if (url.pathname.startsWith("/_next/static/")) { - event.respondWith( - caches.match(request).then( - (cached) => - cached ?? - fetch(request).then((res) => { - const clone = res.clone(); - caches.open(CACHE).then((c) => c.put(request, clone)); - return res; - }) - ) + // Mutations — network-only; notify clients if the network is unreachable. + if (request.method !== "GET") { + e.respondWith( + fetch(request).catch(() => { + self.clients + .matchAll({ includeUncontrolled: true }) + .then((cs) => + cs.forEach((c) => c.postMessage({ type: "OFFLINE_MUTATION" })) + ); + return new Response(JSON.stringify({ error: "offline" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + }) ); return; } - // Navigation requests — network-first, serve offline shell if unreachable. + // Auth endpoints — always network-only. + if (url.pathname.startsWith("/api/auth/")) return; + + // Next.js immutable static chunks — stale-while-revalidate. + if (url.pathname.startsWith("/_next/static/")) { + e.respondWith(staleWhileRevalidate(request, SHELL)); + return; + } + + // API GETs — network-first with 2-second timeout, fall back to cache. + if (url.pathname.startsWith("/api/")) { + e.respondWith(networkFirstWithTimeout(request, API, 2000)); + return; + } + + // Navigation — stale-while-revalidate; fall back to offline page. if (request.mode === "navigate") { - event.respondWith( - fetch(request).catch(() => caches.match(OFFLINE)) - ); + e.respondWith(navigateWithFallback(request)); return; } }); + +// --- strategy helpers --- + +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + const update = fetch(request) + .then((res) => { + if (res.ok) cache.put(request, res.clone()); + return res; + }) + .catch(() => null); + // Serve cached immediately; let update happen in the background. + return cached ?? (await update); +} + +async function networkFirstWithTimeout(request, cacheName, ms) { + const cache = await caches.open(cacheName); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), ms); + try { + const res = await fetch(request, { signal: ac.signal }); + clearTimeout(timer); + if (res.ok) cache.put(request, res.clone()); + return res; + } catch { + clearTimeout(timer); + return (await cache.match(request)) ?? new Response(null, { status: 503 }); + } +} + +async function navigateWithFallback(request) { + const cache = await caches.open(SHELL); + const cached = await cache.match(request); + const networkFetch = fetch(request) + .then((res) => { + if (res.ok) cache.put(request, res.clone()); + return res; + }) + .catch(() => null); + if (cached) { + // Return cached immediately; revalidate in the background. + networkFetch; + return cached; + } + const net = await networkFetch; + if (net) return net; + return ( + (await cache.match(OFFLINE)) ?? new Response("Offline", { status: 503 }) + ); +} diff --git a/src/components/pwa-register.tsx b/src/components/pwa-register.tsx index 20dbbba..39d3613 100644 --- a/src/components/pwa-register.tsx +++ b/src/components/pwa-register.tsx @@ -1,28 +1,115 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; export function PwaRegister() { + const [showUpdate, setShowUpdate] = useState(false); + // Lazy init reads navigator.onLine on the client; SSR always returns false. + const [isOffline, setIsOffline] = useState( + () => typeof navigator !== "undefined" && !navigator.onLine + ); + const [mutationFailed, setMutationFailed] = useState(false); + useEffect(() => { - if (!("serviceWorker" in navigator)) return; + const handleOffline = () => setIsOffline(true); + const handleOnline = () => setIsOffline(false); + window.addEventListener("offline", handleOffline); + window.addEventListener("online", handleOnline); + + if (!("serviceWorker" in navigator)) { + return () => { + window.removeEventListener("offline", handleOffline); + window.removeEventListener("online", handleOnline); + }; + } + + // Capture whether a SW already controls this page before registering. + // If true and the controller later changes, a new build has deployed. + const hadController = !!navigator.serviceWorker.controller; navigator.serviceWorker .register("/sw.js", { scope: "/" }) - .then((reg) => { - reg.addEventListener("updatefound", () => { - const next = reg.installing; - if (!next) return; - next.addEventListener("statechange", () => { - if (next.state === "installed" && navigator.serviceWorker.controller) { - // New version available — the next navigation will pick it up - // because skipWaiting() is called in the SW install handler. - console.info("[famapp] Service worker updated."); - } - }); - }); - }) .catch((err) => console.warn("[famapp] SW registration failed:", err)); + + const handleControllerChange = () => { + if (hadController) setShowUpdate(true); + }; + navigator.serviceWorker.addEventListener( + "controllerchange", + handleControllerChange + ); + + const handleMessage = (e: MessageEvent) => { + if (e.data?.type === "OFFLINE_MUTATION") { + setMutationFailed(true); + setTimeout(() => setMutationFailed(false), 4000); + } + }; + navigator.serviceWorker.addEventListener("message", handleMessage); + + return () => { + window.removeEventListener("offline", handleOffline); + window.removeEventListener("online", handleOnline); + navigator.serviceWorker.removeEventListener( + "controllerchange", + handleControllerChange + ); + navigator.serviceWorker.removeEventListener("message", handleMessage); + }; }, []); - return null; + return ( + <> + {isOffline && ( +