diff --git a/docs/superpowers/plans/2026-07-27-pwa-installability.md b/docs/superpowers/plans/2026-07-27-pwa-installability.md new file mode 100644 index 0000000..511270a --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pwa-installability.md @@ -0,0 +1,503 @@ +# 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. +
  3. Elige Añadir a pantalla de inicio y confirma.
  4. +
+
+
+ + ); + } + 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.