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

99 lines
9.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` `define`s 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.ts``unresolvedConflictsCount` 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.ts``define` for the three version constants
- `apps/web/Dockerfile``GIT_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=…`