From 8aab25e0ac0a930a53b0a4e6bee143d74e0d8835 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 6 May 2026 16:00:02 -0500 Subject: [PATCH] Implement PWA shell (task 50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add web manifest, placeholder icons, app-shell service worker, and install prompt so famapp is installable on iOS and Android home screens. - public/manifest.webmanifest — name, short_name, standalone display, start_url /, all four icon sizes (192/384/512 + maskable) - public/icon-{180,192,384,512,512-maskable}.png — indigo placeholder squares generated by scripts/generate-icons.mjs (pnpm gen:icons) - public/icon.svg — house-icon SVG source for future branded export - public/sw.js — hand-rolled app-shell SW: precaches offline.html, cache-first for /_next/static/, network-first navigation with offline fallback, network-only for /api/* - public/offline.html — branded offline fallback page - src/components/pwa-register.tsx — client SW registration - src/components/install-prompt.tsx — dismissible install banner: beforeinstallprompt on Android, "Add to Home Screen" hint on iOS - Root layout: viewport themeColor, manifest/appleWebApp metadata, apple-touch-icon, mounts PwaRegister + InstallPrompt Co-Authored-By: Claude Sonnet 4.6 --- STATUS.md | 2 + package.json | 3 +- public/icon-180.png | Bin 0 -> 496 bytes public/icon-192.png | Bin 0 -> 547 bytes public/icon-384.png | Bin 0 -> 1289 bytes public/icon-512-maskable.png | Bin 0 -> 1881 bytes public/icon-512.png | Bin 0 -> 1881 bytes public/icon.svg | 12 +++ public/manifest.webmanifest | 32 ++++++++ public/offline.html | 71 +++++++++++++++++ public/sw.js | 69 +++++++++++++++++ scripts/generate-icons.mjs | 101 ++++++++++++++++++++++++ src/app/layout.tsx | 19 ++++- src/components/install-prompt.tsx | 124 ++++++++++++++++++++++++++++++ src/components/pwa-register.tsx | 28 +++++++ 15 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 public/icon-180.png create mode 100644 public/icon-192.png create mode 100644 public/icon-384.png create mode 100644 public/icon-512-maskable.png create mode 100644 public/icon-512.png create mode 100644 public/icon.svg create mode 100644 public/manifest.webmanifest create mode 100644 public/offline.html create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.mjs create mode 100644 src/components/install-prompt.tsx create mode 100644 src/components/pwa-register.tsx diff --git a/STATUS.md b/STATUS.md index 555bbd0..3e17e7d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -27,6 +27,8 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **26 — Customizable layout + widget configuration**. Installed `react-grid-layout` v2. Dashboard pages check `?edit=1` to enter edit mode, rendering a client `DashboardEditor` instead of the static grid. Editor uses `react-grid-layout` with `gridConfig`/`dragConfig` v2 API; each widget shell shows a drag handle, configure button (⚙), and remove button (🗑). `WidgetPicker` is a two-step modal: step 1 lists all registry widgets grouped by category; step 2 is a `WidgetConfigurator` auto-generated from the widget's default config — handles `"all"|string[]` multi-selects, booleans, numbers, and enums. `resolveWidgetConfigOptions` server action fetches dynamic options (calendars, lists). Save validates each config against its registered Zod schema. Reset to defaults calls `computeDefaultLayout()`. `pnpm typecheck`, `pnpm build` pass. - **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. + ## Next up - Next task in `docs/tasks/`. diff --git a/package.json b/package.json index 5cfb3e0..0c50fd8 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:seed": "tsx --env-file=.env scripts/seed.ts", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "gen:icons": "node scripts/generate-icons.mjs" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", diff --git a/public/icon-180.png b/public/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..c2ddc726dc728ca6ac6170ce76e88f6bb5f08d10 GIT binary patch literal 496 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofpNR1i(^Q|oVS+@c^MQK4j5ed z_$Z#i&fq|v#jKD6eZ8jUF}=@OYfj6k6i+zgXvBVUlrbm*fsfVsb-T@q=JmjsWbkzL Kb6Mw<&;$UZ=&|?! literal 0 HcmV?d00001 diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..e755168040e86fad2857a221116320f604d501c3 GIT binary patch literal 547 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$^26i(^Q|oVS+@c^MQK4j5ed z_$Z#i?!y5-r%Owi%5v81e0ZxO?!a{(mm~!t=7|y=M;uhxlnezr8$CvahE_;e&bz-R Vm#g+`C@}IFJYD@<);T3K0RS=gm@WVS literal 0 HcmV?d00001 diff --git a/public/icon-384.png b/public/icon-384.png new file mode 100644 index 0000000000000000000000000000000000000000..1498832dfcfa51c942c077b1de9fab775bbe6e81 GIT binary patch literal 1289 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+k4kn<8_EM!63=Aw6JY5_^D(1YsV#v#2z;j^3 zqTAO07)~V{6rGvG_~g``%?zCL9BXLkVPuqKW-?-9N#Wr*Au%d3 k8YH8MVKf^ImXd-0_nD37Q>rfg1Qu}&p00i_>zopr01p{HeEu7)=GE8DX?67%dJ*M2%3p Y_-n+>#EX*}fK?`gr>mdKI;Vst0HBgi-v9sr literal 0 HcmV?d00001 diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..745c0b98851652d830667a5edf1ce2fb5fa2dc06 GIT binary patch literal 1881 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_pKoPZ!6KiaBqu8uBt2@Eq8% z=(hDghGWSFRYxWu7)=GE8DX?67%dJ*M2%3p Y_-n+>#EX*}fK?`gr>mdKI;Vst0HBgi-v9sr literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..b495eef --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..b470957 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,32 @@ +{ + "name": "famapp", + "short_name": "famapp", + "description": "Family coordination app", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#4F46E5", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..93b31b6 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,71 @@ + + + + + + + famapp — offline + + + +
+ + + + + +
+ +

You're offline

+

famapp needs a connection — check your network and try again.

+ + + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..f50512a --- /dev/null +++ b/public/sw.js @@ -0,0 +1,69 @@ +// 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"; +const OFFLINE = "/offline.html"; + +// --- install: precache offline shell --- + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.add(OFFLINE)) + ); + self.skipWaiting(); +}); + +// --- activate: evict old caches --- + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +// --- fetch --- + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + 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; + }) + ) + ); + return; + } + + // Navigation requests — network-first, serve offline shell if unreachable. + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => caches.match(OFFLINE)) + ); + return; + } +}); diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..9599f78 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,101 @@ +/** + * Generates placeholder PNG icons for the famapp PWA. + * + * Usage: node scripts/generate-icons.mjs + * + * Produces: + * public/icon-192.png + * public/icon-384.png + * public/icon-512.png + * public/icon-512-maskable.png (same image; OS applies its own mask) + * public/icon-180.png (apple-touch-icon) + * + * All images are solid indigo (#4F46E5) squares — replace with a real + * branded export from icon.svg when assets are finalised. + */ + +import { deflateSync } from "zlib"; +import { writeFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const publicDir = resolve(__dir, "../public"); + +// Build CRC-32 lookup table once. +const CRC_TABLE = new Uint32Array(256); +for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + CRC_TABLE[i] = c; +} + +function crc32(buf) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) + crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function u32(n) { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +function pngChunk(type, data) { + const typeBytes = Buffer.from(type, "ascii"); + const crc = u32(crc32(Buffer.concat([typeBytes, data]))); + return Buffer.concat([u32(data.length), typeBytes, data, crc]); +} + +/** + * Returns a Buffer containing a valid PNG of size×size filled with (r, g, b). + */ +function solidPNG(size, r, g, b) { + const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + + const ihdr = pngChunk( + "IHDR", + Buffer.concat([ + u32(size), + u32(size), + Buffer.from([8, 2, 0, 0, 0]), // 8-bit RGB, no interlace + ]) + ); + + // One filter byte (0 = None) followed by size×3 RGB bytes per row. + const rowLen = 1 + size * 3; + const raw = Buffer.alloc(size * rowLen); + for (let y = 0; y < size; y++) { + const base = y * rowLen; + raw[base] = 0; + for (let x = 0; x < size; x++) { + raw[base + 1 + x * 3] = r; + raw[base + 2 + x * 3] = g; + raw[base + 3 + x * 3] = b; + } + } + + const idat = pngChunk("IDAT", deflateSync(raw)); + const iend = pngChunk("IEND", Buffer.alloc(0)); + + return Buffer.concat([PNG_SIG, ihdr, idat, iend]); +} + +// Indigo-600 (#4F46E5) +const [R, G, B] = [0x4f, 0x46, 0xe5]; + +const icons = [ + { name: "icon-192.png", size: 192 }, + { name: "icon-384.png", size: 384 }, + { name: "icon-512.png", size: 512 }, + { name: "icon-512-maskable.png", size: 512 }, + { name: "icon-180.png", size: 180 }, +]; + +for (const { name, size } of icons) { + const out = resolve(publicDir, name); + writeFileSync(out, solidPNG(size, R, G, B)); + console.log(` ✓ public/${name} (${size}×${size})`); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a2ed1bc..db79069 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import "./globals.css"; import { Geist } from "next/font/google"; import { cn } from "@/lib/utils"; @@ -13,12 +13,27 @@ import { getQuickAdds } from "@/modules/_core"; import { QuickAddProvider } from "@/components/quick-add-provider"; import { QuickAddSheet } from "@/components/quick-add-sheet"; import { CommandPalette } from "@/components/command-palette"; +import { PwaRegister } from "@/components/pwa-register"; +import { InstallPrompt } from "@/components/install-prompt"; const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); +export const viewport: Viewport = { + themeColor: "#4F46E5", +}; + export const metadata: Metadata = { title: "famapp", description: "Family coordination app", + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "famapp", + }, + icons: { + apple: "/icon-180.png", + }, }; // Runs before paint — reads localStorage / prefers-color-scheme and applies @@ -82,6 +97,8 @@ export default async function RootLayout({
{children}
+ + diff --git a/src/components/install-prompt.tsx b/src/components/install-prompt.tsx new file mode 100644 index 0000000..576317b --- /dev/null +++ b/src/components/install-prompt.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { X, Share, Plus } from "lucide-react"; + +const DISMISSED_KEY = "pwa-install-dismissed"; + +type Prompt = "android" | "ios"; + +function isIos() { + return ( + /iphone|ipad|ipod/i.test(navigator.userAgent) || + // iPad on iOS 13+ reports as Mac + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) + ); +} + +function isInStandaloneMode() { + return ( + "standalone" in window.navigator && + (window.navigator as { standalone?: boolean }).standalone === true + ); +} + +export function InstallPrompt() { + const [prompt, setPrompt] = useState(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [deferredEvent, setDeferredEvent] = useState(null); + + useEffect(() => { + if (localStorage.getItem(DISMISSED_KEY)) return; + + // Android / Chrome desktop — beforeinstallprompt fires + const handler = (e: Event) => { + e.preventDefault(); + setDeferredEvent(e); + setPrompt("android"); + }; + window.addEventListener("beforeinstallprompt", handler); + + // iOS — no beforeinstallprompt; defer setState out of the synchronous + // effect body to satisfy react-hooks/set-state-in-effect. + let iosTimer: ReturnType | undefined; + if (isIos() && !isInStandaloneMode()) { + iosTimer = setTimeout(() => setPrompt("ios"), 0); + } + + return () => { + window.removeEventListener("beforeinstallprompt", handler); + clearTimeout(iosTimer); + }; + }, []); + + function dismiss() { + localStorage.setItem(DISMISSED_KEY, "1"); + setPrompt(null); + } + + async function install() { + if (!deferredEvent) return; + deferredEvent.prompt(); + const { outcome } = await deferredEvent.userChoice; + if (outcome === "accepted" || outcome === "dismissed") { + dismiss(); + } + } + + if (!prompt) return null; + + return ( +
+ {/* App icon */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ +
+

Add famapp to your home screen

+ + {prompt === "android" && ( + <> +

+ Install for a faster, app-like experience. +

+ + + )} + + {prompt === "ios" && ( +

+ Tap{" "} + {" "} + then{" "} + + +  Add to Home Screen + + . +

+ )} +
+ + +
+ ); +} diff --git a/src/components/pwa-register.tsx b/src/components/pwa-register.tsx new file mode 100644 index 0000000..20dbbba --- /dev/null +++ b/src/components/pwa-register.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; + +export function PwaRegister() { + useEffect(() => { + if (!("serviceWorker" in navigator)) return; + + 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)); + }, []); + + return null; +}