Closes the MVP2 cycle (fase 9-14 all green). Documents the three substantive shipped pieces (14.1 auto-update toast, 14.2 offline polish + sync_conflicts review route, 14.3 build-time version chip), the test deltas, cumulative MVP2 totals, and the two plan deviations (updateViaCache substitute, localStorage-based discard). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
9.0 KiB
9.0 KiB
Fase 14 — PWA hardening (auto-update + offline polish + version chip)
MVP2 · 6/6. Closes the MVP2 cycle.
Status: ✅ Shipped 2026-05-18.
Final polish on the PWA layer that Fase 6 made installable. Plan: plan/fase-14-pwa-hardening.md. No DB migration. Builds on Fase 9.3's sync_conflicts table + queue wiring.
What changed
14.1 — Auto-update toast
apps/web/src/routes/+layout.sveltemigrated fromregisterSW({ immediate: true })touseRegisterSW({ onRegisteredSW, onRegisterError, onNeedRefresh })fromvirtual:pwa-register/svelte. The Svelte flavour exposesneedRefresh/offlineReadyas writable stores; we mirror them into stable layout-local writables so the reference passed into<UpdateToast/>never changes across the dynamic-import gap.- New component
apps/web/src/lib/components/UpdateToast.svelte— pill bottom-center with "New version available" copy + a "Reload" button (callsupdateServiceWorker(true)); a second variant with the "Ready to use offline" message that auto-dismisses after 4 s. - Telemetry:
console.info('[pwa] ...')for register, need-refresh, apply. - Periodic update poll:
setInterval(reg.update, 15 min). Standing in for the unavailableupdateViaCache: 'none'runtime option (see "Deviations" below). - PU-01 Playwright spec in
apps/web/tests/e2e/pwa-update.test.ts. Drives the toast via awindow.__pwaRegisterStubhook the layout checks before calling the realuseRegisterSW— avoids needing two builds + a redeploy mid-test.
14.2 — Offline polish
- New
apps/web/src/lib/components/OfflineChip.svelte— amber pill hidden when online, shown inMobileTopBar(compact icon-only variant) and the bottom ofDesktopSidebar(full pill with label). WhenpendingOpsCount > 0, appends a small numeric badge so the user sees the backlog size at a glance. - New route
apps/web/src/routes/(app)/settings/sync-conflicts/+page.svelte— paginated list of the user's last 100 conflicts, local + remote payloads side-by-side, two dismiss actions ("Discard local" / "Discard remote"). Both dismiss actions write the row id to asyncConflictsDismissedlocalStorage set — migration 016 markssync_conflictsappend-only at the RLS layer (no DELETE policy) and Fase 14 ships no migration, so dismissal is per-device. The underlying log is preserved for operator audits. apps/web/src/lib/stores/syncStatus.tsgainsunresolvedConflictsCount.(app)/+layout.svelteprobessync_conflictsonce per session (deferred out of the auth callback for the same lock-deadlock reason the collectives query is), subtracts the dismissed set, and surfaces an amber banner pill with a "Review" link to the route. Banner is suppressed while the user is already on the review route.- Background-sync fallback for Safari (
attachOnlineFlushinapps/web/src/lib/sync/queue.ts) was already wired inapps/web/src/lib/sync/index.ts. No change needed for 14.2.4. - OF-01 e2e: drives
context.setOffline(true), asserts the chip + numeric badge appear, then disappear back online. - OF-02 e2e: plants a
sync_conflictsrow through the dev-only__sbwindow client, reloads, asserts banner → click → row visible on the route → discard → empty state. Pre-dismisses every existing conflict via localStorage so the test is deterministic regardless of accumulated history (we can't DELETE from the table).
14.3 — Version chip + GIT_SHA bake-through
apps/web/vite.config.tsdefines three build-time constants:__APP_VERSION__← rootpackage.jsonversion__APP_COMMIT__←process.env.GIT_SHA→ localgit rev-parse --short HEAD→'dev'__BUILD_TIME__←new Date().toISOString()at build time
- Declarations in new
apps/web/src/global.d.ts(which also declares thevirtual:pwa-register/sveltemodule shape so svelte-check seesuseRegisterSW). - New
apps/web/src/lib/version.tsre-exports as named bindings{ version, commit, builtAt }. Components import from$lib/version— never inline the magic globals — so an accidental tree-shake breaks the V-02 unit test, not the page silently. /settingsgains an "About" section at the bottom:v{version} · {commit} · {date}in muted text. Not interactive.apps/web/Dockerfileaccepts aGIT_SHAbuild arg and exports it as an env var for the SvelteKit build step.infra/docker-compose.erosi.ymlforwards${GIT_SHA:-dev}into the build args.infra/scripts/deploy-erosi.shcapturesgit rev-parse --short HEADon the deploy host (rsync excludes.git, so the remote can't compute it) and forwards viassh ... env GIT_SHA=… bash -s.- V-01/V-02 vitest in
apps/web/src/lib/version.test.tsstub the three globals viavi.stubGlobal(vitest doesn't run through the mainvite.configsodefineisn't applied).
Test deltas (Fase 14 only)
| Suite | Before | After | Δ |
|---|---|---|---|
pgTAP (just test-db) |
166 | 166 | 0 |
Vitest integration (just test-integration) |
173 passed + 3 skipped | 173 + 3 skipped | 0 |
Vitest unit (just test-unit) |
45 | 47 | +2 (V-01, V-02) |
Playwright e2e (just test-e2e) |
86 | 89 | +3 (PU-01, OF-01, OF-02) |
| Gated rate-limit | 1 | 1 | 0 |
All green. The plan estimated +1 vitest + 3 playwright; actual is +2 vitest + 3 playwright (the version barrel needed two assertions to cover both the named exports AND the "no undefined / 'dev' sentinel" canary).
Cumulative MVP2 test counts (sum across Fase 9–14)
| Suite | Count |
|---|---|
| pgTAP | 166 |
| Vitest integration | 173 (+ 3 skipped) |
| Vitest unit | 47 |
| Playwright e2e | 89 |
| Gated rate-limit | 1 |
| Total active | 476 (+ 3 skipped) |
Deviations from plan
updateViaCache: 'none'is in the plan but not exposed byvite-plugin-pwa0.21.2'sRegisterSWOptions(workbox-window 7 doesn't expose it on the underlyingWorkboxconstructor either). Workaround: an explicitreg.update()poll every 15 min fromonRegisteredSW. Same end result for the "stale SW file" scenario the option targets, no plugin upgrade required.sync_conflicts"Discard" buttons are per-device (localStorage), not per-account. The plan implied a clean removal; migration 016 explicitly makes the table append-only at the RLS layer and Fase 14 ships no migration, so a real delete would require either a new migration or a SECURITY DEFINER RPC. Filed as a follow-up rather than rolled into Fase 14: the log is informational, the local hide is good UX, and the underlying rows remain available for operator audits.- Lighthouse PWA score: deferred.
just lighthouserequires a built bundle + a separate Node process to spawn the server; the assistant could not run the headed browser in this environment. The PWA prerequisites (manifest shape, SW registration, scope, display=standalone, 192+512 icons) are covered by P-01/P-02 e2e specs and unchanged from Fase 6's audit; no regression expected. Recommend runningjust lighthouseagainst the next deploy and recording the score in the next session.
Risks captured in the plan (for future-self)
skipWaiting()is aggressive: the new SW takes control as soon as the user clicks "Reload". A user with in-flight optimistic mutations could lose them if they're not yet flushed to IDB at the moment of reload. Mitigated: the queue persists to IDB BEFORE the network call (seesrc/lib/sync/queue.ts); the reload only kills in-memory state, not the queue.onNeedRefreshonly fires if Workbox detects a changedrevisionin the precache manifest. A deploy that only changes runtime env (e.g. a Keycloak URL update) does not bump any hashed asset and the toast won't fire. Documented in the runbook: bumppackage.jsonversion any time you want the toast to surface, even for env-only deploys.
Files added
apps/web/src/lib/components/UpdateToast.svelteapps/web/src/lib/components/OfflineChip.svelteapps/web/src/lib/version.tsapps/web/src/lib/version.test.tsapps/web/src/global.d.tsapps/web/src/routes/(app)/settings/sync-conflicts/+page.svelteapps/web/tests/e2e/pwa-update.test.tsdocs/history/fase-14-pwa-hardening.md(this file)
Files modified
apps/web/src/routes/+layout.svelte— useRegisterSW migration, UpdateToast mount, periodic SW pollapps/web/src/routes/(app)/+layout.svelte— conflict probe + bannerapps/web/src/routes/(app)/settings/+page.svelte— About chipapps/web/src/lib/stores/syncStatus.ts—unresolvedConflictsCountstoreapps/web/src/lib/components/layout/MobileTopBar.svelte—<OfflineChip compact />apps/web/src/lib/components/layout/DesktopSidebar.svelte—<OfflineChip />apps/web/messages/{en,es}.json— 17 new keys (pwa_*, sync_conflicts_*, settings_about_*)apps/web/vite.config.ts—definefor the three version constantsapps/web/Dockerfile—GIT_SHAbuild argapps/web/tests/e2e/offline.test.ts— OF-01 + OF-02 alongside existing O-01/O-02infra/docker-compose.erosi.yml— forwardGIT_SHAinto the buildinfra/scripts/deploy-erosi.sh— capture local SHA + sshenv GIT_SHA=…