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>
This commit is contained in:
98
docs/history/fase-14-pwa-hardening.md
Normal file
98
docs/history/fase-14-pwa-hardening.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 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 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 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=…`
|
||||
Reference in New Issue
Block a user