Files
AgendaPro/docs/superpowers/plans/2026-07-27-pwa-installability.md
T

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 /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:

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.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:

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-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<void>; dismiss: () => void }.

  • InstallPromptState is exactly "unsupported" | "available" | "ios-instructions" | "installed".

  • <InstallAppPrompt /> 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:

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 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:

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: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:

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:

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.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.