Installable, offline-capable, push-enabled products on the web — the decision, the lane, and how it is verified

PWA

The engine's lane for Progressive Web Apps. Say it in prose — "make balqalam installable", "attendance should work offline", "push notifications for parents" — and the /pwa runbook activates with the pwa agent, the pwa pattern card, and the audit script.

The phone is the client and there is no native app budget. Teachers mark attendance on phones, parents read absences on phones, hosts answer guests on phones. A PWA gives them a home-screen icon, an offline shell and browser push with no app store, no Swift or Kotlin build, and no per-platform cost. Cash flow first: the paying pilot runs on the web app, so the web app has to earn the home screen.

The decision (2026-09-12)

ProductVerdictWhy
hogwartsyesDaily field flows on phones (quick attendance, kiosk, parent portal). A PWA layer already shipped and was half-broken. The notification model already carries the push columns and a cron.
mkandeferGuests are one-visit and contact-only — the only volume event is a phone call. Hosts have no real message volume yet. Reopen when host messaging moves; fix the canonical host, themeColor and dictionary every string first.

Framed under Lean Startup (canon #13/#15) as a hypothesis with a metric: an installed app and offline attendance raise the share of daily attendance submitted from phones. Measured by the outbox applied rate and attendance rows per week; reviewed after four weeks.

What was actually broken

Verified live on demo.balqalam.com and kingfahd.balqalam.com before any code changed:

  • Five icons the manifest and worker named returned 500 — shortcut icons, push icons, the iOS home-screen icon.
  • The manifest was static, English and single-tenant on an Arabic-default multi-tenant product.
  • The worker precached / and /offline, both 307s to the locale prefix. A redirected response is refused for navigations, so the offline fallback never worked — silently.
  • Every navigation and every /api/ GET was cached with no user key: a shared school device could replay the previous user's dashboard.
  • The push handler hardcoded a product name, dead icons, and a click that always opened /.

The lane

PWA Lane

A product a phone can install — audited, built per tenant, pushed through the deploy lane, verified on the live hosts

pwa
pwa

audit <url> — install criteria, every icon, the precache list, theme-color

pwa
pwa

build — icons, per-tenant manifest, service worker, offline page, outbox kind

pwa
pwa

push — PushSubscription, VAPID, web-push processor on the push cron, preferences toggle

che
check

tsc + build + i18n validate

dep
deploy

Cloudflare cycle: restore point, DDL, secrets, smoke, real-login verify

wat
watch

Audit both tenants live; offline page renders; push round-trip

ModeWhat happens
auditpwa-audit.mjs <url> --sw /service-worker.js — install criteria, every icon, the precache list for redirects, theme-color and apple-touch-icon; then the browser half (controller, offline, install)
buildIcons → root layout → per-tenant manifest.ts → service worker → registration → offline page → outbox kind
pushPushSubscription model → VAPID keys → web-push processor on the push cron → preferences toggle

Lighthouse 12 removed its PWA category; the audit script is the audit.

The pattern

The full layout is the pwa pattern card. The load-bearing choices:

  • The manifest resolves its own tenant. Middleware matchers exclude dotted paths, so /manifest.webmanifest never sees the tenant headers. The route reads the host, resolves the school, and answers with its name, language, direction and colour. start_url stays relative.
  • Hand-rolled worker on large apps. A 30 MB app's whole-bundle precache is hostile to Sudan and UAE mobile data. The worker precaches only the locale offline pages, caches hashed static assets lazily with a cap, and keeps /api/ network-only. Signed-in pages are saved only inside a per-session namespace (below). Serwist is the answer for a small greenfield app.
  • Outbox, not cache, for offline writes. IndexedDB holds the pending work; the sync route applies each kind idempotently and answers applied, duplicate or rejected.
  • Push on the existing rails. Web Push adds a subscription store and a processor; the FCM scaffold stays a no-op until a native app wants it.
  • iOS order. No install prompt event; Web Push reaches installed apps only — install first, push second.

One domain rule guards the worker: next-16/sw-no-authenticated-cache auto-loads whenever a service worker file is touched.

What shipped on hogwarts (2026-09-12)

Four phases, four commits on main, one deploy to balqalam.com (Worker 7e2f5791, since superseded by the rotation and bridge deploys):

PhaseWhat a school gets
1An installable app named after the school, Arabic and right-to-left by default, in the school's colour; real icons; a service worker that keeps only public assets and shows the locale's offline page when the connection drops
2Quick attendance that survives a dropped connection: marks queue on the phone and replay through the same ownership check as the online path; a newer mark on the server wins
3Web Push on the existing notification rails: a device subscribes from the preferences page; absence, exam and live-class notices arrive as system notifications and deep-link into the school's app; iPhone only from the installed app
4An App Store-style install row on phone-sized dashboards — orange feather icon, app name, one-line blurb, an arrow pill — the real install prompt on Android; on iPhone the native share sheet (Add to Home Screen is one of its actions) with a two-step guide underneath (2026-09-13)

Explorable offline on hogwarts (2026-09-13/14)

The second pass made the dashboard usable without a connection, not just survivable. What a school gets: every page a teacher opens on a good connection opens again offline, as it last was, under a strip that says so. A slow network shows the saved copy after four seconds, marked as saved, with Retry. A page never opened says "not on this device yet" with a Back button, because an installed iPhone app has none. The installed app also saves the whole sidebar once a day.

What it took, and the traps each rule answers:

RuleWhy
Save signed-in pages only in a cache named by a session key the server sends (x-session-key); a response with another key, or none, drops the restA shared school device must never replay the previous person's screens
The sign-out button and the sign-in and join pages tell the worker to forget everything before they navigateOn a slow network a saved copy is served before any response can reveal a new person
Save each opened page as HTML, once a dayAn offline click never repeats an online _rsc: a prefetched route sends a partial router tree, an offline click the whole one. Measured: Xdsm5spjRVlRmBSs online, AwDuXUM9oR_42pvt offline
Key any saved RSC payload WITH _rscThe payload is a patch against the tree it was requested from. Stripped, a students → teachers payload drew the dashboard under the teachers URL
Leave Next's experimental.useOffline offWith it on, a sidebar click sent no request and nothing moved; its scheduler stops fetching while offline, the likely cause
Keep the offline page and its files in their own untrimmed cache, refreshed dailyIts scripts were the oldest entries in the capped static cache, so they were the first evicted

Measured on a local production build, with the server stopped for offline checks and a proxy holding every response 5 s for slow ones: an opened page offline in 283 ms, the saved copy at 4.0 s on the slow network, saved pages gone before the sign-out request returned, and the next account's first page waiting 5.1 s for its own content. One saved dashboard page is 1.37 MB, mostly the dictionary. The same pass removed a proxy cookie that made every Server Action re-render the page (1.1 MB each) and put the static files behind Cloudflare's edge cache.

How to experience it

On any school host today (demo.balqalam.com, kingfahd.balqalam.com):

  1. Install — sign in on a phone and a welcome sheet slides up over the dashboard showing where Add to Home Screen sits; tap Continue: Android opens the install sheet, iPhone opens the native share sheet where you pick Add to Home Screen. The home-screen icon opens the school full-screen with its own name and colour.
  2. Mark attendance offline — Attendance → Quick, airplane mode on, mark a section, save; the sync banner shows one pending item. Network back on → it drains within seconds and the marks are on the server. Save again and the newer mark wins.
  3. Turn on push — Notifications → Preferences → Push, allow the permission. Have a teacher mark a child absent: on the next cron tick (every 15 minutes) the guardian's phone shows the absence and a tap opens it in the app.
  4. Lose the connection anywhere — airplane mode: pages opened before render as last seen under the offline strip; a page never opened shows "not on this device yet" with Back and Retry, and the outbox lists what is queued.
  5. Share a phone — sign out and sign in as someone else: nothing the first account saved appears, even on a slow network.

The user-facing walkthrough lives in the product docs: hogwarts Offline, Attendance and Notifications pages, in English and Arabic.

Verified, and owed

Verified by tooling on production: the install audit passes on both hosts (0 FAIL), the worker controls the page after one reload with both locale offline pages precached, the outbox replay answers applied then duplicate, and the push cron reports the channel configured. Owed to a human with a phone, because no tooling can grant the notification permission or cut the network under the service worker: a push actually showing on a device, and the offline page under a real disconnect — tracked in hogwarts#408. Lean Startup review date: 2026-10-10 (outbox applied rate, attendance rows per week).

Verification

  1. Audit → 0 FAIL: Arabic name, dir: rtl, every icon 200, no redirecting precache entry.
  2. Browser: the worker controls the page after one reload. For offline, stop the server or use a phone in airplane mode — DevTools' Offline emulation does not reach the worker's own fetches. An opened page renders from its saved copy; an unopened one shows the not-saved card.
  3. Outbox: a write made offline drains on reconnect and replays as duplicate.
  4. Push: subscribe, queue one notification, hit the cron — it lands and its tap deep-links to the tenant route.
  5. Sessions: sign out, sign in as another account — no page saved by the first. Repeat behind a proxy that delays responses past the worker's timeout, from a freshly loaded page.

Agents · Keywords · Rules · Deploy — prod DDL for the subscription store goes through the deploy skill's schema-gap step behind a Neon restore point.