# AgendaMax PWA Installability Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make AgendaMax installable as a web app on Android, iOS, and compatible desktop browsers without adding offline writes or changing the existing API/authentication model. **Architecture:** Add a hand-maintained web manifest and service worker under `public/`, register the worker only in production, and provide a reusable React install prompt. Chromium uses `beforeinstallprompt`; iOS Safari receives explicit Share -> Add to Home Screen instructions. The worker caches only the app shell/static assets and bypasses all API and non-GET requests. **Tech Stack:** React 18, TypeScript, Vite 5, Tailwind CSS, Express static hosting, Playwright, Node.js 22.5+. ## Global Constraints - Use a manually maintained manifest and service worker; do not add `vite-plugin-pwa`. - Keep the data model online-first; never cache `/api` responses or queue offline writes. - Keep AgendaMax's existing Spanish visual language and make installation a secondary action. - Production installation requires HTTPS; `localhost` is valid for local testing. - Do not change authentication, tenant isolation, API routes, or database schema. - Preserve the existing `public/favicon.svg` and brand color `#3b66ff`. - Keep service-worker registration failure non-fatal and log it only in development. ## File Map - Create `public/manifest.webmanifest`: browser install metadata and icon declarations. - Create `public/sw.js`: versioned shell/static-asset cache with API bypass. - Create `public/icon-192.png` and `public/icon-512.png`: install icons derived from the existing favicon mark. - Create `scripts/generate-pwa-icons.mjs`: reproducible local icon generation using the existing Playwright dependency. - Create `src/lib/useInstallPrompt.ts`: browser capability detection and deferred install event lifecycle. - Create `src/components/InstallAppPrompt.tsx`: install button and iOS instruction modal. - Create `pwa-e2e.mjs`: production-server PWA endpoint and browser-behavior checks. - Modify `index.html`: manifest link, iOS metadata, and safe-area viewport setting. - Modify `src/main.tsx`: production service-worker registration. - Modify `src/index.css`: safe-area utility for the install dialog. - Modify `src/pages/LoginPage.tsx`, `src/components/AppShell.tsx`, and `src/components/AdminShell.tsx`: render the reusable install action in the existing UI surfaces. - Modify `package.json`: icon-generation and PWA test scripts. - Modify `README.md`: document install behavior, HTTPS, and validation commands. --- ### Task 1: Add Static PWA Metadata and Icons **Files:** - Create: `scripts/generate-pwa-icons.mjs` - Create: `public/icon-192.png` - Create: `public/icon-512.png` - Create: `public/manifest.webmanifest` - Modify: `index.html:5-12` - Modify: `package.json:7-25` **Interfaces:** - Produces `/manifest.webmanifest`, `/icon-192.png`, and `/icon-512.png` in the Vite output. - Keeps `/favicon.svg` as the browser-tab icon. - [ ] **Step 1: Add a reproducible icon generator.** Create `scripts/generate-pwa-icons.mjs` using the already-installed Playwright package. It must load `public/favicon.svg` as a data URL, render it on a white 1:1 page, and screenshot exactly 192x192 and 512x512 PNG files: ```js import { chromium } from "playwright"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const svg = await fs.readFile(path.join(root, "public", "favicon.svg"), "utf8"); const browser = await chromium.launch({ headless: true }); try { for (const size of [192, 512]) { const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 }); await page.setContent(`${svg}`); await page.screenshot({ path: path.join(root, "public", `icon-${size}.png`), type: "png" }); await page.close(); } } finally { await browser.close(); } ``` - [ ] **Step 2: Run the generator and verify PNG dimensions.** Run: ```text node scripts/generate-pwa-icons.mjs ``` Expected: `public/icon-192.png` and `public/icon-512.png` exist. Verify their PNG signature and dimensions with a short Node check before continuing; do not substitute SVG files for the required PNG icons. - [ ] **Step 3: Add the manifest.** Create `public/manifest.webmanifest` with this exact contract: ```json { "name": "AgendaMax", "short_name": "AgendaMax", "description": "Gestión visual de citas, empleados e ingresos para tu negocio.", "start_url": "/", "scope": "/", "display": "standalone", "orientation": "portrait-primary", "background_color": "#f6f7fb", "theme_color": "#3b66ff", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ] } ``` - [ ] **Step 4: Update HTML metadata and package scripts.** In `index.html`, keep the existing favicon/theme/description and add: ```html ``` Change the viewport content to include `viewport-fit=cover` while preserving the current scale values. Add these scripts to `package.json`: ```json "generate:pwa-icons": "node scripts/generate-pwa-icons.mjs", "test:pwa": "node pwa-e2e.mjs" ``` - [ ] **Step 5: Build and inspect static output.** Run: ```text npm.cmd run build ``` Expected: exit code 0 and `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist. The worker is added in Task 2, so do not treat its absence as a failure in this task. - [ ] **Step 6: Commit the static PWA contract.** ```text git add package.json index.html public/manifest.webmanifest public/icon-192.png public/icon-512.png scripts/generate-pwa-icons.mjs git commit -m "feat: add AgendaMax PWA metadata and icons" ``` --- ### Task 2: Add the Production Service Worker **Files:** - Create: `public/sw.js` - Modify: `src/main.tsx:18-25` **Interfaces:** - Browser loads `/sw.js` from the same origin in production. - The worker owns only shell/static caching; API requests remain network-only. - [ ] **Step 1: Add the service worker with explicit routing.** Create `public/sw.js` with a versioned cache and these rules: ```js const CACHE_NAME = "agendamax-shell-v1"; const SHELL_URLS = ["/", "/manifest.webmanifest", "/favicon.svg", "/icon-192.png", "/icon-512.png"]; self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)).then(() => self.skipWaiting()) ); }); self.addEventListener("activate", (event) => { event.waitUntil( caches.keys() .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) .then(() => self.clients.claim()) ); }); self.addEventListener("fetch", (event) => { const { request } = event; const url = new URL(request.url); if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return; if (request.mode === "navigate") { event.respondWith( fetch(request) .then((response) => { if (response.ok) { const copy = response.clone(); void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); } return response; }) .catch(() => caches.match("/")) ); return; } const cacheableDestination = new Set(["script", "style", "image", "font", "manifest", "worker"]); if (!cacheableDestination.has(request.destination)) return; event.respondWith( caches.match(request).then((cached) => { if (cached) return cached; return fetch(request).then((response) => { if (response.ok) { const copy = response.clone(); void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); } return response; }); }) ); }); ``` The worker must not add a catch-all cache path, intercept non-GET requests, or cache requests whose path starts with `/api/`. - [ ] **Step 2: Register the worker only in production.** Append this registration after the React root render in `src/main.tsx`: ```ts if (import.meta.env.PROD && "serviceWorker" in navigator) { window.addEventListener("load", () => { void navigator.serviceWorker .register("/sw.js", { updateViaCache: "none" }) .catch((error: unknown) => { if (import.meta.env.DEV) console.warn("AgendaMax service worker registration failed", error); }); }); } ``` The registration failure is non-fatal and must never reject or delay React startup; the development-only warning is retained for local diagnostics if the environment condition is changed during debugging. - [ ] **Step 3: Validate worker behavior statically.** Run: ```text npm.cmd run typecheck npm.cmd run build ``` Expected: both exit 0, and `dist/sw.js` exists because Vite copies `public/sw.js`. - [ ] **Step 4: Commit the worker.** ```text git add public/sw.js src/main.tsx git commit -m "feat: add production PWA service worker" ``` --- ### Task 3: Add Install Detection and UI **Files:** - Create: `src/lib/useInstallPrompt.ts` - Create: `src/components/InstallAppPrompt.tsx` - Modify: `src/index.css:228-322` - Modify: `src/pages/LoginPage.tsx:1-6` and rendered layout - Modify: `src/components/AppShell.tsx:1-18` and mobile header - Modify: `src/components/AdminShell.tsx:1-14` and mobile header **Interfaces:** - `useInstallPrompt(): { state: InstallPromptState; install: () => Promise; dismiss: () => void }`. - `InstallPromptState` is exactly `"unsupported" | "available" | "ios-instructions" | "installed"`. - `` renders nothing for `unsupported` or `installed` and owns the iOS `Modal` lifecycle. - [ ] **Step 1: Define the deferred prompt type and write the hook contract.** Create `src/lib/useInstallPrompt.ts` with a local event type instead of adding an unsafe global declaration: ```ts import { useEffect, useState } from "react"; export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed"; interface BeforeInstallPromptEvent extends Event { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; } function isStandalone() { return window.matchMedia("(display-mode: standalone)").matches || ("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone)); } function isIOSSafari() { const ua = navigator.userAgent; const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua); } export function useInstallPrompt() { const [state, setState] = useState(() => { if (typeof window === "undefined") return "unsupported"; if (isStandalone()) return "installed"; return isIOSSafari() ? "ios-instructions" : "unsupported"; }); const [deferred, setDeferred] = useState(null); const [dismissed, setDismissed] = useState(false); useEffect(() => { if (isStandalone()) { setState("installed"); return; } const onBeforeInstallPrompt = (event: Event) => { event.preventDefault(); setDeferred(event as BeforeInstallPromptEvent); setState("available"); }; const onInstalled = () => { setDeferred(null); setState("installed"); }; window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); window.addEventListener("appinstalled", onInstalled); return () => { window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); window.removeEventListener("appinstalled", onInstalled); }; }, []); const install = async () => { if (!deferred) return; const event = deferred; setDeferred(null); await event.prompt(); const choice = await event.userChoice; if (choice.outcome === "accepted") setState("installed"); else setDismissed(true); }; return { state: dismissed ? "unsupported" : state, install, dismiss: () => setDismissed(true) }; } ``` Keep the hook browser-only and ensure event listeners are removed on unmount. The iOS state is intentionally limited to Safari; unsupported browsers remain normal web pages. - [ ] **Step 2: Build the reusable prompt component.** Create `src/components/InstallAppPrompt.tsx` using `useInstallPrompt`, `Modal`, and `Download`, `Share`, and `PlusSquare` icons from `lucide-react`. The component must: ```tsx export function InstallAppPrompt() { const { state, install } = useInstallPrompt(); const [iosOpen, setIosOpen] = useState(false); if (state === "unsupported" || state === "installed") return null; if (state === "ios-instructions") { return ( <> setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio.">
  1. Toca Compartir en la barra de Safari.
  2. Elige Añadir a pantalla de inicio y confirma.
); } return ; } ``` The final component may use `aria-label`/`aria-labelledby` as needed, but must keep the exact accessible action name `/Instalar AgendaMax/`, close the iOS modal on backdrop/Escape through the existing `Modal`, and never show an install CTA after standalone detection. - [ ] **Step 3: Add safe-area styling.** Append a focused utility to `src/index.css`: ```css .safe-area-bottom { padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); } ``` Apply it to the iOS modal content/footer surface only; do not change the global page height or reserve a permanent bottom band. - [ ] **Step 4: Integrate one visible action per page shell.** Import `InstallAppPrompt` and render it once in each of these existing surfaces: - `LoginPage`: immediately below the login card's submit area, using a compact centered container so it does not affect the desktop brand panel. - `AppShell`: in the existing mobile header beside the AgendaMax wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` responsive convention. - `AdminShell`: in the existing mobile header beside the Admin wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` convention. Do not put the component inside `SidebarContent`, because that JSX is rendered once for desktop and again when the mobile drawer opens. One instance per shell avoids duplicate deferred prompt listeners. - [ ] **Step 5: Verify TypeScript and responsive rendering.** Run: ```text npm.cmd run typecheck npm.cmd run build ``` Expected: exit code 0 for both. Confirm the app still loads the login page and both authenticated shells without console errors. - [ ] **Step 6: Commit the install UI.** ```text git add src/lib/useInstallPrompt.ts src/components/InstallAppPrompt.tsx src/index.css src/pages/LoginPage.tsx src/components/AppShell.tsx src/components/AdminShell.tsx git commit -m "feat: add cross-platform PWA install prompt" ``` --- ### Task 4: Add PWA Verification and Production Documentation **Files:** - Create: `pwa-e2e.mjs` - Modify: `README.md:47-64` **Interfaces:** - `npm run test:pwa` checks a built production server at `PWA_BASE_URL` or `http://localhost:3000`. - The check exits non-zero on missing assets, invalid manifest fields, unexpected API interception, or UI behavior failures. - [ ] **Step 1: Add endpoint and manifest assertions.** In `pwa-e2e.mjs`, fetch the base URL and assert status 200 for `/manifest.webmanifest`, `/sw.js`, `/icon-192.png`, and `/icon-512.png`. Parse the manifest and assert `display === "standalone"`, `start_url === "/"`, `scope === "/"`, and both declared icon sizes. Fetch a known SPA route such as `/calendar` and assert it returns the built HTML rather than a 404. Fetch `/api/health` and assert it remains a JSON API response. - [ ] **Step 2: Add browser install-event checks.** Use Playwright Chromium with `BASE = process.env.PWA_BASE_URL || "http://localhost:3000"`. Add an init script that dispatches a cancelable `beforeinstallprompt` event after load and records `prompt()` calls: ```js await page.addInitScript(() => { window.__installPromptCalls = 0; setTimeout(() => { const event = new Event("beforeinstallprompt", { cancelable: true }); event.prompt = async () => { window.__installPromptCalls += 1; }; event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" }); window.dispatchEvent(event); }, 100); }); ``` On `/`, assert the accessible `Instalar AgendaMax` action becomes visible, click it, and assert `window.__installPromptCalls === 1`. - [ ] **Step 3: Add iOS and standalone checks.** Create a second context with an iPhone Safari user agent. Before page scripts run, define `Navigator.prototype.standalone` as `false`; assert the install action is visible, click it, and assert the modal contains `Compartir` and `Añadir a pantalla de inicio`. Create a third context whose `matchMedia` returns `matches: true` for `display-mode: standalone`; assert no `Instalar AgendaMax` action is visible. Create a fourth normal context without an install event and assert no install action is visible. - [ ] **Step 4: Add service-worker/API safety checks.** After loading the production page, wait for `navigator.serviceWorker.ready` with a bounded timeout. Inspect the worker source returned by `/sw.js` and assert it contains the `/api/` bypass and `request.method !== "GET"` guard. Use Playwright request listeners to confirm normal page/API loading still reaches `/api/health`; do not use a cached fixture as a substitute for the real API response. - [ ] **Step 5: Document local and production validation.** Add a `## Instalar AgendaMax como app` section to `README.md` after the production commands: - Android/Chrome: open the HTTPS URL and select the install action shown by AgendaMax or the browser address bar. - iPhone/iPad Safari: use Share -> Add to Home Screen; iOS does not expose Android's in-page install prompt. - The installed shell can be reopened without browser chrome, but business data still requires an internet connection. - Local validation: run `npm.cmd run build`, start production with `npm.cmd start`, then run `npm.cmd run test:pwa`. - Production validation requires the Coolify HTTPS domain, not an HTTP IP address. - [ ] **Step 6: Run the complete verification set.** With a built production server running on port 3000, run: ```text npm.cmd run typecheck npm.cmd run build npm.cmd run test:pwa npm.cmd run audit:visual git diff --check ``` Expected: typecheck, build, PWA checks, and visual audit exit 0; `git diff --check` reports no whitespace errors. If the pre-existing scheduling data causes API booking assertions in unrelated suites, record that limitation without weakening PWA checks. - [ ] **Step 7: Commit verification and docs.** ```text git add pwa-e2e.mjs README.md git commit -m "test: verify AgendaMax PWA installation" ``` --- ## Final Review Checklist - [ ] `manifest.webmanifest` has valid name, scope, start URL, standalone display, theme/background colors, and 192/512 PNG icons. - [ ] `index.html` includes manifest, iOS metadata, `apple-touch-icon`, and `viewport-fit=cover`. - [ ] `sw.js` is copied into `dist`, updates by version, falls back only for navigation, and bypasses `/api` and non-GET requests. - [ ] Service-worker registration runs only in production and cannot block React startup. - [ ] Android/Chromium install prompt, iOS instructions, unsupported browser, dismissed prompt, and standalone states are covered. - [ ] Login, business mobile shell, and admin mobile shell each have one install action instance. - [ ] No authentication, tenant data, API routes, database schema, or offline writes changed. - [ ] `npm run typecheck`, `npm run build`, `npm run test:pwa`, and the visual audit have evidence before claiming completion.