Files
collective-lists/docs/history/fase-14-pwa-hardening.md
Oier Bravo Urtasun 7bf9135f33 docs(fase-14): history record — PWA auto-update + offline polish + version chip
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>
2026-05-18 07:26:46 +02:00

9.0 KiB
Raw Permalink Blame History

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.svelte migrated from registerSW({ immediate: true }) to useRegisterSW({ onRegisteredSW, onRegisterError, onNeedRefresh }) from virtual:pwa-register/svelte. The Svelte flavour exposes needRefresh / offlineReady as 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 (calls updateServiceWorker(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 unavailable updateViaCache: 'none' runtime option (see "Deviations" below).
  • PU-01 Playwright spec in apps/web/tests/e2e/pwa-update.test.ts. Drives the toast via a window.__pwaRegisterStub hook the layout checks before calling the real useRegisterSW — 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 in MobileTopBar (compact icon-only variant) and the bottom of DesktopSidebar (full pill with label). When pendingOpsCount > 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 a syncConflictsDismissed localStorage set — migration 016 marks sync_conflicts append-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.ts gains unresolvedConflictsCount. (app)/+layout.svelte probes sync_conflicts once 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 (attachOnlineFlush in apps/web/src/lib/sync/queue.ts) was already wired in apps/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_conflicts row through the dev-only __sb window 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.ts defines three build-time constants:
    • __APP_VERSION__ ← root package.json version
    • __APP_COMMIT__process.env.GIT_SHA → local git 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 the virtual:pwa-register/svelte module shape so svelte-check sees useRegisterSW).
  • New apps/web/src/lib/version.ts re-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.
  • /settings gains an "About" section at the bottom: v{version} · {commit} · {date} in muted text. Not interactive.
  • apps/web/Dockerfile accepts a GIT_SHA build arg and exports it as an env var for the SvelteKit build step. infra/docker-compose.erosi.yml forwards ${GIT_SHA:-dev} into the build args. infra/scripts/deploy-erosi.sh captures git rev-parse --short HEAD on the deploy host (rsync excludes .git, so the remote can't compute it) and forwards via ssh ... env GIT_SHA=… bash -s.
  • V-01/V-02 vitest in apps/web/src/lib/version.test.ts stub the three globals via vi.stubGlobal (vitest doesn't run through the main vite.config so define isn'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 914)

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 by vite-plugin-pwa 0.21.2's RegisterSWOptions (workbox-window 7 doesn't expose it on the underlying Workbox constructor either). Workaround: an explicit reg.update() poll every 15 min from onRegisteredSW. 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 lighthouse requires 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 running just lighthouse against 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 (see src/lib/sync/queue.ts); the reload only kills in-memory state, not the queue.
  • onNeedRefresh only fires if Workbox detects a changed revision in 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: bump package.json version any time you want the toast to surface, even for env-only deploys.

Files added

  • apps/web/src/lib/components/UpdateToast.svelte
  • apps/web/src/lib/components/OfflineChip.svelte
  • apps/web/src/lib/version.ts
  • apps/web/src/lib/version.test.ts
  • apps/web/src/global.d.ts
  • apps/web/src/routes/(app)/settings/sync-conflicts/+page.svelte
  • apps/web/tests/e2e/pwa-update.test.ts
  • docs/history/fase-14-pwa-hardening.md (this file)

Files modified

  • apps/web/src/routes/+layout.svelte — useRegisterSW migration, UpdateToast mount, periodic SW poll
  • apps/web/src/routes/(app)/+layout.svelte — conflict probe + banner
  • apps/web/src/routes/(app)/settings/+page.svelte — About chip
  • apps/web/src/lib/stores/syncStatus.tsunresolvedConflictsCount store
  • apps/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.tsdefine for the three version constants
  • apps/web/DockerfileGIT_SHA build arg
  • apps/web/tests/e2e/offline.test.ts — OF-01 + OF-02 alongside existing O-01/O-02
  • infra/docker-compose.erosi.yml — forward GIT_SHA into the build
  • infra/scripts/deploy-erosi.sh — capture local SHA + ssh env GIT_SHA=…