# 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.">
Toca Compartir en la barra de Safari.
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.