# Comprehensive PWA Conversion Guide
**Target project:** `invcount` (PHP/Composer inventory management app)

---

## 1 — What Is a PWA & Why Convert

A Progressive Web App (PWA) is a website that app-lives like a native mobile/desktop app — installable from the browser, works offline, and comes with a home-screen icon. For `invcount` (an inventory management application with role-based access for controllers, admins, and counters), a PWA delivers three immediate business ROI outcomes:

| Benefit | What It Means for `invcount` |
|---|---|
| **Offline access** | Controllers and counters in warehouse zones with no network can still open ongoing sessions and reference their assignment lists. |
| **Install & pin** | Users install `invcount` from any supported browser; it sits alongside their other work apps with a dedicated icon. |
| **Resume-state** | On reload after a network dropout the last-known session page returns instantly without re-auth first. |
| **Push notifications** | (Prerequisite) Alerts auto-fire to mobile/desktop for session-level events (counts submitted, reports ready). |

PWAs are **not** a replacement for native mobile apps. They are a lightweight upgrade to an existing web experience that runs everywhere a browser runs.

---

## 2 — PWA Installability Checklist (Before Writing Any Code)

> **Chrome DevTools → Application → Manifest** must pass **every** item below before the browser will offer the install prompt.

| Criterion | Required Value | Why It Matters |
|---|---|---|
| **Secure context** | HTTPS (or `localhost`) | `localhost` is the only accepted non-HTTPS origin for SW registration and manifest validation. |
| **Manifest served** | `Content-Type: application/manifest+json` with correct path in `<link>` tag | Browsers ignore manifests with a wrong MIME type. |
| **Manifest `name` or `short_name`** | Non-empty string | Displayed to the user in the install prompt. |
| **Manifest `start_url`** | Absolute or relative URL resolving to the app root (e.g. `/` or `/dashboard.php`) | Determines what the app opens to on launch. |
| **Manifest `icons`** | ≥ 1 icon, each ≥ 192×192 **and** ≥ 512×512 (PNG, minimum) | The 512×192 pair is the Chrome OS / Windows / Android installable set. |
| **Manifest `display`** | `standalone`, `fullscreen`, or `minimal-ui` | `browser` disables installability. |
| **Manifest `scope`** | URL pattern covering the entire site (e.g. `/`) | If the SPA is mounted to `/app/` use scope `"/app/"` — the default here is `/` because `invcount` spans many PHP files off the root. |
| **Service Worker** | Registered, active, and responding `fetch` events | The SW *must* intercept network requests; `devtools` shows status `activated` / `running`. |
| **`beforeinstallprompt` not suppressed** | No inline `event.preventDefault()` on the browser's install event | Otherwise `navigator.serviceWorker.getRegistrations()` returns nothing new. |

If even one item fails, Chrome/Firefox silently drops the install bubble — there is no error log for this; only DevTools → Application → Manifest tells you which field is blank.

---

## 3 — Step-by-Step Implementation

### 3.1 Web App Manifest — `manifest.json`

Place this file in the **document root** of `invcount` (next to `index.php`, `dashboard.php`). For Composer-based projects it can also live in `public/` – adjust the `<link>` href accordingly.

#### `manifest.json` (complete canonical version)

```json
{
  "name": "Inventory Counter",
  "short_name": "InvCounter",
  "description": "Inventory Count Management System — real-time tracking for controllers, counters, and admins.",
  "start_url": "/invcount/login.php",
  "scope": "/invcount/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "background_color": "#1a1a2e",
  "theme_color": "#16213e",
  "categories": ["business", "productivity"],
  "lang": "en-US",
  "dir": "ltr",
  "related_applications": [],
  "prefer_related_applications": false,
  "screenshots": [
    {
      "src": "/invcount/images/screenshots/dashboard-desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Dashboard view"
    },
    {
      "src": "/invcount/images/screenshots/dashboard-mobile.png",
      "sizes": "720x1280",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Mobile dashboard view"
    }
  ],
  "icons": [
    {
      "src": "/invcount/images/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/invcount/images/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/invcount/images/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/invcount/images/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/invcount/images/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/invcount/images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/invcount/images/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/invcount/images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}
```

#### Key field decisions for `invcount`

| Field | Value | Rationale |
|---|---|---|
| `start_url` | `/invcount/login.php` | The install flow **must** begin at `login.php` so that any unauthenticated user lands on the credential gate, not directly at a role-specific dashboard. After successful login the role-based redirect (admin → `/admin/dashboard.php`, counter → `/counter/dashboard.php`, controller → `/controller/dashboard.php`, checker → `/checker/dashboard.php`) is handled server-side by `index.php`. Relative paths without a leading query or hash are used to avoid breaking the "add to home screen" flow. |
| `scope` | `/invcount/` | Covers every PHP route under the `invcount` path. Because the application is hosted at `https://<host>/invcount/` the scope must also be `/invcount/` so the browser does not constrict the installable URL space to the server root. |
| `display` | `standalone` | Removes the browser URL bar and tabs — the app immerses fully, just like a native desktop app. |
| `background_color` | `#1a1a2e` | Matches `invcount`'s dark navy theme; used as splash-screen background while assets load. |
| `theme_color` | `#16213e` | Defined in `config/theme.php` as the site primary colour; passed to the OS taskbar/tab strip on launch. |
| `purpose` | `maskable` on 192/512 | `maskable` lets Adaptive Icons (Android 8+) render the icon on shapes other than squares without cutting off content. |

---

### 3.2 HTTPS / Secure Context

A PWA **cannot** run on plain HTTP — Service Worker registration, the manifest fetch, and `beforeinstallprompt` all guard with `isSecureContext`. For `invcount` being served from a **local development stack (XAMPP/LAMP/WAMP)** on `localhost` the user already has an exception; for **staging and production** the steps are:

#### Development (localhost :8000)
PHP's built-in server is already secure by spec:
```bash
# From the invcount root
php -S localhost:8000
```
No TLS needed. All `isSecureContext` checks resolve to `true` on `localhost`.

#### Staging / Production (real domain)
1. **Acquire a free TLS certificate** — use [Let's Encrypt](https://letsencrypt.org/) via Certbot or acme.sh; both support Apache, Nginx, and Caddy.
   ```bash
   # Let's Encrypt via trailblazer for Apache
   certbot --apache -d inventory.example.com
   ```
2. **Redirect all HTTP → HTTPS** at the web-server level:
   ```apache
   # Apache — .htaccess in document root (already check for invcount)
   <IfModule mod_rewrite.c>
       RewriteEngine On
       RewriteCond %{HTTPS} !=on
       RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
   </IfModule>
   ```
3. **Enable HSTS** — optionally add this header to force browsers to remember HTTPS:
   ```apache
   <IfModule mod_headers.c>
       Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
   </IfModule>
   ```
4. **Verify** — open `https://inventory.example.com`, then in DevTools Console:
   ```js
   window.isSecureContext   // → true
   ```

#### Mixed-content lock-down checklist

| Asset | Must Be | Reason |
|---|---|---|
| Service worker file | HTTPS | Fetched as a sub-resource; mixed-content is blocked at fetch time. |
| Manifest | HTTPS | Chromium silently discards manifests loaded over mixed content. |
| Icons | HTTPS or same-origin | Any cross-origin `<img>` inside the manifest breaks install. |
| All `<script>` / `<link>` | HTTPS | Modern browsers actively block mixed-script pages. |

---

### 3.3 Service Worker Registration — `register-sw.js`

Place in `assets/js/register-sw.js`. Link it from **every** page template (or from the shared header partial so it lives on every rendered view).

#### `assets/js/register-sw.js`

```javascript
/**
 * register-sw.js
 * Safe, error-tolerant Service Worker registration.
 * Call this from a <script> tag on every page.
 */
function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) {
    console.info('[PWARegister] Service Workers are not supported in this browser.');
    return;
  }

  const url = '/sw.js';

  navigator.serviceWorker
    .register(url, { scope: '/' })
    .then(registration => {
      console.info('[PWARegister] SW registered.', {
        registration,
        scope: registration.scope,
        scriptURL: registration.scriptURL,
      });

      // Precaching after version bump — force refresh of updated assets
      registration.addEventListener('updatefound', () => {
        const installing = registration.installing;
        console.info('[PWARegister] SW update detected; installing =', installing);

        installing.addEventListener('statechange', () => {
          if (installing.state === 'installed' && navigator.serviceWorker.controller) {
            console.info('[SW] New version ready; prompt user to refresh.');
            // Optionally show an in-app toast/banner here
          }
        });
      });

      // Watch for controller changes — dismissed when a new SW takes over
      navigator.serviceWorker.addEventListener('controllerchange', () => {
        console.info('[PWARegister] Controller changed — new SW is now active.');
        window.location.reload();
      });
    })
    .catch(error => {
      console.error('[PWARegister] Registration failed:', error);
    });
}

// Register after DOM is ready to avoid script-order race conditions
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', registerServiceWorker);
} else {
  registerServiceWorker();
}
```

#### Bootstrap from the shared header — `includes/header.php`

The `invcount` project has a shared header (`includes/header.php`). Inject the registration call there so it runs on every page — controllers, counters, and admins alike.

```php
<!-- includes/header.php — just before the closing </body> tag -->
<script src="/assets/js/register-sw.js" defer></script>
<script>
  // Listen for the browser's install prompt and surface an in-app button
  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault();                                        // don't show browser popup yet
    window.__deferredInstallPrompt = e;                        // stash it
    const installBtn = document.createElement('button');
    installBtn.id = 'install-pwa-btn';
    installBtn.textContent = 'Install Inventory Counter';
    installBtn.style.cssText = 'display:block;margin:1rem auto;padding:.75rem 1.5rem;'
                             + 'background:#16213e;color:#fff;border:none;'
                             + 'border-radius:8px;cursor:pointer;font-size:1rem;';
    document.body.appendChild(installBtn);

    installBtn.addEventListener('click', async () => {
      const promptEvent = window.__deferredInstallPrompt;
      if (!promptEvent) return;
      promptEvent.prompt();
      const { outcome } = await promptEvent.userChoice;
      console.log('[PWA] Install prompt outcome =', outcome);
      window.__deferredInstallPrompt = null;
      installBtn.remove();
    });
  });
</script>
```

> **Why `<script defer>` here and not block rendering?** The registration fires while the page is still parsing, and `DOMContentLoaded` guards against double-calls. No impact on Largest Contentful Paint.

---

### 3.4 Service Worker Implementation — `assets/js/sw.js`

The SW is the centerpiece of the PWA. Every strategy below maps to a real caching pattern used by production PWAs (Chrome DevX → Workbox).

```javascript
/**
 * assets/js/sw.js
 * Inventory Counter PWA — Service Worker
 * Strategy selection:
 *   NetworkFirst  — API responses, session data (freshness required)
 *   CacheFirst   — Static assets (CSS, JS, images, fonts; speed required)
 *   NetworkOnly  — Auth / payment endpoints (never cache)
 *   StaleWhileRevalidate — Dashboard shell (speed + freshness balance)
 */

'use strict';
const CACHE_NAMES = {
  core:          'invcount-core-v1',    // static files — prune on version bump
  dynamic:       'invcount-dynamic-v1', // API responses — short TTL
  images:        'invcount-images-v1',  // product / session photos
  fonts:         'invcount-fonts-v1',   // web fonts
  fallback:      'invcount-fallback',
};

// ── Installation ──────────────────────────────────────────────────────────────
self.addEventListener('install', event => {
  console.info('[SW] Install event.');
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAMES.core);
      console.info('[SW] Precaching core shell…');
      const coreAssets = [
        '/invcount/login.php',
        '/invcount/dashboard.php',
        '/css/site.css',
        '/js/app.bundle.js',
        '/images/icons/icon-192x192.png',
        '/js/register-sw.js',
      ];
      await cache.addAll(coreAssets);
      console.info('[SW] Core shell precached.');
      self.skipWaiting();   // activate immediately without waiting for tab close
    })()
  );
});

// ── Activation ────────────────────────────────────────────────────────────────
self.addEventListener('activate', event => {
  console.info('[SW] Activate event.');
  event.waitUntil(
    (async () => {
      // Delete every cache whose name we no longer recognise
      const allNames = await caches.keys();
      const toDelete = allNames.filter(name =>
        !Object.values(CACHE_NAMES).includes(name)
      );
      await Promise.all(toDelete.map(name => caches.delete(name)));
      console.info('[SW] Deleted', toDelete.length, 'old cache(s).');
      // Take control of all open pages immediately
      await self.clients.claim();
      self.__activeVersion = CACHE_NAMES.core;  // used for debug / version bump detection
    })()
  );
});

// ── Fetch routing ──────────────────────────────────────────── (the actual SW engine)
self.addEventListener('fetch', event => {
  const { request } = event;
  const url  = new URL(request.url);

  // ── 1.  Bypass the SW entirely for non-GET requests ─────────────────────────
  if (request.method !== 'GET') { return; }

  // ── 2.  Bypass the SW for cross-origin API calls ────────────────────────────
  if (url.origin !== self.location.origin) { return; }

  // ── 3.  GET-cacheable paths ─────────────────────────────────────────────────
  const path = url.pathname;

  if (path.startsWith('/api/') || path.startsWith('/ajax/') || path.includes('api_locations.php')) {
    // ── API endpoints → NetworkFirst (freshness matters) ─────────────────────
    event.respondWith(networkFirst(request, CACHE_NAMES.dynamic, 300));
  }
  else if (path.match(/\.(css|js|woff2?|ttf|eot)$/i)) {
    // ── Static assets → CacheFirst ────────────────────────────────────────────
    // CacheOnly: Precache only, don't save uncached runtime fetches.
    // CacheFirst: Saves on first request — easier initial deploy.
    event.respondWith(cacheFirst(request, CACHE_NAMES.core));
  }
  else if (path.match(/\.(png|jpe?g|webp|avif|svg|gif)$/i)) {
    // ── Images → CacheFirst with backfill ────────────────────────────────────
    event.respondWith(cacheFirst(request, CACHE_NAMES.images));
  }
  else if (path.match(/\/fonts\//i)) {
    // ── Google Fonts / local @font-face → CacheFirst ──────────────────────────
    event.respondWith(cacheFirst(request, CACHE_NAMES.fonts));
  }
  else if (/^\/invcount\/login\.php/.test(request.url) || /^\/invcount\/dashboard\.php/.test(request.url) || path === '/invcount/') {
    // ── Login & dashboard shell → StaleWhileRevalidate
    // The first launch lands on login.php (PWA start_url); the always-fresh copy is
    // rebuilt there. Once authenticated the role redirect to admin/counter/checker/
    // dashboard is handled by index.php — those pages use NetworkFirst below.
    event.respondWith(staleWhileRevalidate(request, CACHE_NAMES.core));
  }
  else {
    // ── All other PHP pages (Sessions, Items, Reports…) → NetworkFirst ────────
    // Auth-dependent pages should never be served from a stale cache.
    event.respondWith(networkFirst(request, CACHE_NAMES.core));
  }
});

// ─────────────────────────────────────────────────────────────────────────────
// Caching strategy helpers
// ─────────────────────────────────────────────────────────────────────────────

/**
 * NETWORK-FIRST with fallback to cache.
 * Freshness is the priority; serve stale only while the network is indigestible.
 * Use for: API responses, auth-required pages, session data.
 */
async function networkFirst(request, cacheName, maxAgeMs = 0) {
  const cache = await caches.open(cacheName);
  try {
    const networkResponse = await fetch(request, { credentials: 'same-origin' });
    if (networkResponse && networkResponse.status === 200) {
      // Clone before consuming the body (stream can be read only once)
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (networkError) {
    const cached = await cache.match(request);
    if (!cached) {
      // Return application-specific offline fallback page
      const offlinePage = await caches.match('/offline.html');
      return offlinePage || new Response('Offline — please reconnect.', {
        status: 503,
        headers: { 'Content-Type': 'text/plain' },
      });
    }
    console.warn('[SW/Fallback]', request.url, 'served from cache (network down).');
    return cached;
  }
}

/**
 * CACHE-FIRST — serve from cache, fall back to network.
 * Speed is the priority. Use for: static assets that won't change between SW updates.
 */
async function cacheFirst(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  if (cached) { return cached; }
  try {
    const networkResponse = await fetch(request);
    if (networkResponse && networkResponse.status === 200) {
      await cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch {
    return new Response('Asset not available offline.', {
      status: 503,
      headers: { 'Content-Type': 'text/plain' },
    });
  }
}

/**
 * STALE-WHILE-REVALIDATE — return cached copy immediately, then refresh in background.
 * Best of both worlds: instant paint + eventual freshness.
 * Use for: the dashboard shell, shared layouts.
 */
async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  const fetchPromise = fetch(request).then(response => {
    if (response && response.status === 200) {
      cache.put(request, response.clone());
    }
    return response;
  }).catch(() => undefined);

  // Return cached immediately; fire-and-forget the network refresh
  return cached || fetchPromise;
}

// ── Push / Background Sync hooks (ready for future use) ──────────────────────
self.addEventListener('push', event => {
  if (!event.data) { return; }
  const data = event.data.json();
  self.registration.showNotification(data.title ?? 'Inventory Counter', {
    body:   data.body ?? 'A new event was recorded.',
    icon:   '/images/icons/icon-192x192.png',
    badge:  '/images/icons/icon-72x72.png',
    data:   data.url ?? '/invcount/login.php',
  });
});

self.addEventListener('notificationclick', event => {
  event.notification.close();
  clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
    for (const client of list) {
      if (client.url === event.notification.data && 'focus' in client) {
        return client.focus();
      }
    }
    if (clients.openWindow) {
      clients.openWindow(event.notification.data);
    }
  });
});

self.addEventListener('message', event => {
  // Respond to skipWaiting / claim requests from register-sw.js
  if (event.data === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});
```

#### Inline caching strategy (no-fetch lifecycle) — `offline.html`

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>You're offline — Inventory Counter</title>
  <style>
    body { font-family: system-ui; text-align:center; padding:3rem; background:#1a1a2e; color:#fff; }
    h1  { font-size:2rem; margin-bottom:.5rem; }
    p   { color:#ccc; }
  </style>
</head>
<body>
  <h1>No connection</h1>
  <p>You're offline — previously-opened pages are still available.</p>
  <p>Reconnect to sync new count sessions.</p>
</body>
</html>
```

Save `offline.html` in the document root alongside `manifest.json`.

---

### 3.5 Icon Asset Requirements

The manifest requires **at minimum** two icons at specific pixel sizes. Browsers pick the closest match to the device's icon grid.

#### Required icon sizes (minimum set)

| Size | Android / Chrome OS usage | iOS Safari home-screen | Favicon browser tab |
|---|---|---|---|
| 72×72  | Low-res notification | — | — |
| 96×96  | — | — | — |
| 128×128| — | — | — |
| 144×144| Legacy Android widget | — | — |
| 152×152| — | Older iOS | — |
| **192×192** | **Chrome add-to-home-screen (required)** | — | — |
| 384×384| Android Adaptive Icon layer 1 | — | — |
| **512×512** | **Android splash screen, Chrome default (required)** | — | — |

#### Generating all sizes from a single SVG or 1024×1024 PNG source

```bash
# Install sharp (Node.js) — one-time
npm install sharp --no-save

# Generate all sizes from a 1024x1024 source PNG
npx sharp ./images/icons/source-1024.png \
  --resize  72  72   --output ./images/icons/icon-72x72.png   && \
npx sharp ./images/icons/source-1024.png \
  --resize  96  96   --output ./images/icons/icon-96x96.png   && \
npx sharp ./images/icons/source-1024.png \
  --resize 128 128  --output ./images/icons/icon-128x128.png  && \
npx sharp ./images/icons/source-1024.png \
  --resize 144 144  --output ./images/icons/icon-144x144.png  && \
npx sharp ./images/icons/source-1024.png \
  --resize 152 152  --output ./images/icons/icon-152x152.png  && \
npx sharp ./images/icons/source-1024.png \
  --resize 192 192  --output ./images/icons/icon-192x192.png  && \
npx sharp ./images/icons/source-1024.png \
  --resize 384 384  --output ./images/icons/icon-384x384.png  && \
npx sharp ./images/icons/source-1024.png \
  --resize 512 512  --output ./images/icons/icon-512x512.png
```

> **Icon design rules:** Use a solid background on 192/512 PNGs so the OS can render them legibly in the taskbar bar. If you want Android Adaptive Icons (`purpose: any maskable`) to clip safely inside a circle or rounded-square, leave at least **10% padding** from the pixel edge. Icons with text inside are strongly discouraged at small sizes — render them as a monochrome silkscreen-compatible glyph.

#### Adding a favicon to match the PWA set

```html
<!-- includes/header.php — <head> -->
<link rel="icon"        type="image/png" sizes="32x32"   href="/images/icons/icon-192x192.png">
<link rel="icon"        type="image/png" sizes="16x16"   href="/images/icons/icon-144x144.png">
<link rel="apple-touch-icon"                  sizes="180x180" href="/images/icons/icon-180x180.png">
```

---

### 3.6 HTML Link Injection — Every Page Template

All five page entry points in `invcount` must carry the manifest and SW registration:

```php
<!-- includes/header.php — inside <head> -->
<!-- Web App Manifest -->
<link rel="manifest" href="/manifest.json">

<!-- Theme colour for mobile browser chrome -->
<meta name="theme-color" content="#16213e">

<!-- Viewport — mandatory for PWA install -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">

<!-- iOS: disable Safari's UI chrome when added to home screen -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="InvCounter">

<!-- End of <head> -->
```

Add this `<meta>` also to the bottom of `includes/footer.php` so it is in every rendered page:

```php
</body>
</html>
```

---

## 4 — Caching Patterns Deep Dive

### 4.1 Why Not Cache POST APIs?

The Service Worker `fetch` handler **explicitly passes through** POST (or anything other than GET) requests unchanged. Caching a `POST` side-effect means:

1. The successful `POST /api/sessions/create` on the first attempt gets cached.
2. The second device hitting the SW serves the *response* of the first device's `POST` — a stale count record appears from a different user.
3. Any mutation endpoint — including session creation, item updating, role promotion — can silently duplicate, lose, or corrupt inventory data.

**Rule of thumb:** Cache only idempotent GET responses in the SW. All state-modifying endpoints go through the network untouched.

### 4.2 Cache invalidation strategy

```javascript
// In register-sw.js — force-update after a new deploy
self.addEventListener('message', (event) => {
  // Called from the page after deploy
  if (event.data.tag === 'force-update') {
    const newCacheName = 'invcount-core-v' + Date.now();
    // copy everything from old cache to new cache
  }
});
```

For `invcount` specifically, **version the CACHE_NAMES** whenever you deploy a change to CSS, JS, or the manifest. The `activate` handler removes all unknown caches automatically — stale assets are gone without manual invalidation.

---

## 5 — Handling Dynamic PHP Content in the SW

`invcount` is a classic multi-page PHP application. Service Workers understand only HTTP requests — they are oblivious to sessions, cookies, and role logic. The mapping:

| Route pattern | Caching | Auth / session cookie concern |
|---|---|---|
| `/dashboard.php` | StaleWhileRevalidate | `php` session cookie is sent as `credentials: 'same-origin'` — cached response is per-cookie, not shared across users |
| `/sessions.php` (admin) | NetworkFirst | Same cookie scoping — safe |
| `/sessions.php` (counter) | NetworkFirst | Same cookie scoping |
| `/api/*.php` | NetworkFirst | Pin same-origin credential policy; do not cache if response sets `Cache-Control: no-store, private` |
| Any `/admin/*`.php page | NetworkFirst — never cache assetized PHP | Admin pages are session-authenticated server-side — the client's stale-cached copy bypasses the auth middleware |

**The `credentials: 'same-origin'` option** in all three fetch handlers ensures the PHP session cookie travels with every SW-initiated request. Without it, Congress's `auth.php` session guard would redirect to `login.php` — silently.

```javascript
// Inside all fetch() calls
const networkResponse = await fetch(request, { credentials: 'same-origin' });
```

---

## 6 — Incremental Updates & Cache Versioning

Every time you release new CSS, JS, icons, or a new manifest, update the cache name:

```javascript
const CACHE_NAMES = {
  core:    'invcount-core-v2',   // ← bump from v1 to v2
  dynamic: 'invcount-dynamic-v2',
};
```

When `v2` becomes available:
1. `install` event precaches the new `v2` entries.
2. `activate` event deletes the old `v1` cache.
3. `skipWaiting()` in `install` + `controllerchange` handler reloads all open tabs to v2 automatically.

No stale UI. No user confusion.

---

## 7 — Testing & Debugging

### 7.1 DevTools checklist

| Panel | What to verify |
|---|---|
| **Application → Manifest** | All required fields filled; no red squiggles |
| **Application → Service Workers** | Status = `activated and is running` |
| **Network → Disable cache** | Offline checkbox ticked; navigate from `/invcount/login.php` to the role dashboard (login flow → redirect → dashboard) — every page must still render from the SW cache |
| **Application → Storage → IndexedDB** | Any app-state DB is present |
| **Application → Installability** | Chromium shows `Manifest\installability_eligible: true` |

### 7.2 Lighthouse PWA audit

```bash
# Install Lighthouse CLI
npm i -g lighthouse

# Run audit against local dev server
lighthouse http://localhost:8000 --view --output-path=lighthouse-report.html
```

Target score: **≥ 90 / 100** on both `pwa` and `performance` categories.

### 7.3 Install prompt simulation in DevTools

1. Open DevTools → `Application → Service Workers`.
2. Click the three-dot menu on the row for `sw.js`.
3. Pick "Update on reload" — this forces a fresh install of the SW for each tab refresh.
4. Go to `Console`, run `navigator.serviceWorker.getRegistration().then(r => r.showPrompt())` — this fires the install bubble for DevTools-based installations (not all browsers support this call).

---

## 8 — Performance Review PWA Checklist

| Metric | Target | Where to measure |
|---|---|---|
| **Largest Contentful Paint (LCP)** | < 2.5 s | Chrome DevTools → Performance |
| **First Input Delay (FID)** | < 100 ms | Chrome DevTools → Performance |
| **Cumulative Layout Shift (CLS)** | < 0.1 | Chrome DevTools → Performance |
| **Cumulative Resource Usage (CRU)** | SW active, intercepting fetch | Application → Service Workers |
| **Page weight (first load)** | ≤ 300 KB gzip | Network → Size column |

**Quick wins for `invcount`:**
- Move `app.bundle.js` from sync-script to `<script defer>`.
- Convert the session listing page to `StaleWhileRevalidate` rather than `CacheFirst`.
- Add `preload` hints for `site.css` in the `<head>`.
- Consider `Link rel="preconnect"` for any Google Fonts domain used in `header.php`.

---

## 9 — Deployment Checklist

Run this list in order before marking a release as PWA-ready.

```
[ ] manifest.json is at the document root AND is served with
    Content-Type: application/manifest+json
[ ] All eight PWA icon PNG files are present at the paths referenced
    in the manifest and are being served as image/png (not text/*)
[ ] sw.js is at the path given in register-sw.js (/assets/js/sw.js)
[ ] manifest.json <link> tag is in every template file
    (header.php / footer.php covers all routes)
[ ] <meta name="theme-color"> is defined in the <head>
[ ] HTTPS is enforced (all HTTP requests redirect to HTTPS)
[ ] Mixed-content blocked: no http://, ws://, or inline script in
    pages served over HTTPS — test with DevTools Console open
[ ] Application → Service Workers shows status = activated and running
[ ] Lighthouse PWA score ≥ 90
[ ] "Add to home screen" / install option works on Chrome Android
    AND on Safari iOS (add Share → Add to Home Screen)
[ ] App loads and navigates when Network → Offline is enabled
[ ] Auth redirects still work in offline mode (they should show the
    cached login / offline page, not a stale old session page)
```

---

## Appendix A — `invcount`-specific Notes

### Role-aware start_url

If `invcount` wants each role to open its own tile:

```json
"shortcuts": [
  {
    "name": "My Session",
    "short_name": "Session",
    "description": "Open current count session",
    "url": "/sessions.php?id={{CURRENT_SESSION_ID}}",
    "icons": [{ "src": "/images/icons/icon-96x96.png", "sizes": "96x96" }]
  }
]
```

> ⚠️ **Do not embed cookies or filenames in `start_url` or `shortcuts.url`.** Protocol-level tokens expire. Use server-injected `{{CURRENT_SESSION_ID}}` server-side at HTML render time, not at SW install time. SWs are static files and have no PHP execution context.

### Avoid caching PDF export paths (`count_report.php`)

```javascript
// Add to the SW fetch matcher — treat PDF exports as aphorism-only
} else if (path.match(/\.(pdf|csv|xlsx?)$/i)) {
  event.respondWith(networkOnly(request));
}
```

---

*Guide prepared for the `invcount` inventory redemption platform (PHP/Composer). Last reviewed: May 2026.*
