21 KiB
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
/apiresponses or queue offline writes. - Keep AgendaMax's existing Spanish visual language and make installation a secondary action.
- Production installation requires HTTPS;
localhostis valid for local testing. - Do not change authentication, tenant isolation, API routes, or database schema.
- Preserve the existing
public/favicon.svgand 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.pngandpublic/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, andsrc/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.pngin the Vite output. -
Keeps
/favicon.svgas 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:
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(`<!doctype html><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#fff}svg{display:block;width:100%;height:100%}</style>${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:
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:
{
"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:
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="AgendaMax" />
Change the viewport content to include viewport-fit=cover while preserving the current scale values. Add these scripts to package.json:
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
"test:pwa": "node pwa-e2e.mjs"
- Step 5: Build and inspect static output.
Run:
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.
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.jsfrom 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:
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:
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:
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.
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-6and rendered layout - Modify:
src/components/AppShell.tsx:1-18and mobile header - Modify:
src/components/AdminShell.tsx:1-14and mobile header
Interfaces:
-
useInstallPrompt(): { state: InstallPromptState; install: () => Promise<void>; dismiss: () => void }. -
InstallPromptStateis exactly"unsupported" | "available" | "ios-instructions" | "installed". -
<InstallAppPrompt />renders nothing forunsupportedorinstalledand owns the iOSModallifecycle. -
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:
import { useEffect, useState } from "react";
export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
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<InstallPromptState>(() => {
if (typeof window === "undefined") return "unsupported";
if (isStandalone()) return "installed";
return isIOSSafari() ? "ios-instructions" : "unsupported";
});
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(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:
export function InstallAppPrompt() {
const { state, install } = useInstallPrompt();
const [iosOpen, setIosOpen] = useState(false);
if (state === "unsupported" || state === "installed") return null;
if (state === "ios-instructions") {
return (
<>
<button type="button" className="btn btn-secondary w-full justify-start" onClick={() => setIosOpen(true)}>
<Download className="h-4 w-4" /> Instalar AgendaMax
</button>
<Modal open={iosOpen} onClose={() => setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio.">
<div className="safe-area-bottom">
<ol className="space-y-3 text-sm text-slate-600">
<li className="flex gap-3"><Share className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Toca <strong>Compartir</strong> en la barra de Safari.</span></li>
<li className="flex gap-3"><PlusSquare className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Elige <strong>Añadir a pantalla de inicio</strong> y confirma.</span></li>
</ol>
</div>
</Modal>
</>
);
}
return <button type="button" className="btn btn-secondary w-full justify-start" onClick={() => void install()}><Download className="h-4 w-4" /> Instalar AgendaMax</button>;
}
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:
.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 samelg:hiddenresponsive convention.AdminShell: in the existing mobile header beside the Admin wordmark/menu controls; keep it hidden on large screens with the samelg:hiddenconvention.
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:
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.
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:pwachecks a built production server atPWA_BASE_URLorhttp://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:
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 withnpm.cmd start, then runnpm.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:
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.
git add pwa-e2e.mjs README.md
git commit -m "test: verify AgendaMax PWA installation"
Final Review Checklist
manifest.webmanifesthas valid name, scope, start URL, standalone display, theme/background colors, and 192/512 PNG icons.index.htmlincludes manifest, iOS metadata,apple-touch-icon, andviewport-fit=cover.sw.jsis copied intodist, updates by version, falls back only for navigation, and bypasses/apiand 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.