### Fase 14 — PWA hardening (auto-update + offline polish + version chip)

**MVP2 · 6/6.** Última fase del ciclo MVP2 (rama `mvp2`, fases 9 → 14).

**Estado: 📋 Planificada. Pulido del PWA que Fase 6 dejó funcional pero crudo. Sin migraciones de DB. Depende de Fase 9.3 (wiring de `sync_conflicts`) para el badge de conflictos.**

**Objetivo: convertir el PWA de "técnicamente instalable" a "se actualiza solo, avisa cuando estás offline, y muestra qué versión corres". Tres asks pequeños, una fase pequeña.**

**Estado actual (auditado el 2026-05-18):**
- `apps/web/src/sw.ts` ya llama `self.skipWaiting()` y `clientsClaim()` — el SW nuevo toma control en cuanto entra (no requiere cerrar todas las pestañas).
- `apps/web/src/routes/+layout.svelte:15` registra con `registerSW({ immediate: true })` sin callbacks. No hay UX para avisar al usuario de update.
- Estrategia `generateSW` activa (ver gotcha "injectManifest hardcoded filename" en CLAUDE.md). Workbox precachea assets.
- IndexedDB offline-first existe (CLAUDE.md "Sync Strategy"). `sync_conflicts` se llena pero no se enseña.

---

#### 14.1 Auto-update con toast

**Ficheros:** `apps/web/src/routes/+layout.svelte`, nuevo `apps/web/src/lib/components/UpdateToast.svelte`.

- [ ] **14.1.1** Migrar de `registerSW({ immediate: true })` a `useRegisterSW({ onNeedRefresh, onOfflineReady })` desde `virtual:pwa-register/svelte`.
- [ ] **14.1.2** `UpdateToast.svelte` — pill flotante bottom-center: "Nueva versión disponible — toca para actualizar" + botón "Recargar". Click invoca `updateSW(true)` (force reload).
- [ ] **14.1.3** Cuando `onOfflineReady` dispara la primera vez (instalación inicial), toast distinto: "Lista para usar offline". Auto-dismiss a 4s.
- [ ] **14.1.4** Telemetría mínima (`console.info` con prefijo `[pwa]`): registro, update detectado, update aplicado. Sin externalizar — solo facilitar debug de campo.
- [ ] **14.1.5** Test Playwright `tests/e2e/pwa-update.test.ts` PU-01: deploy mock con nuevo SW → toast aparece → click recarga → app sirve nuevo bundle. Stubs vía `addInitScript` para simular `onNeedRefresh`.

---

#### 14.2 Offline polish

**Ficheros:** `apps/web/src/lib/components/SyncBanner.svelte` (existe), `apps/web/src/lib/stores/network.ts` (verificar; crear si no existe), `apps/web/src/routes/(app)/+layout.svelte`.

**Auditoría previa:** `SyncBanner` ya existe. Verificar si maneja `navigator.onLine` + listener `online`/`offline`. Reusar lo que haya.

- [ ] **14.2.1** Indicador explícito de estado offline: chip pequeño persistente en `MobileTopBar` y `DesktopSidebar` cuando `!navigator.onLine`. Color `bg-amber-500/10 text-amber-700`. Tooltip: "Sin conexión — los cambios se sincronizarán al volver".
- [ ] **14.2.2** Badge con número de operaciones encoladas. Lee del store de sync existente (verificar nombre en `apps/web/src/lib/stores/`). 0 → sin badge; >0 → número junto al chip offline.
- [ ] **14.2.3** Surface de conflictos (depende de Fase 9.3): si hay filas en `sync_conflicts` para el usuario, banner amarillo en `(app)/+layout.svelte` con link a `/settings/sync-conflicts` (nueva ruta — tabla de conflictos con opción "descartar local" / "descartar remoto").
- [ ] **14.2.4** Fallback de Background Sync API en Safari (gotcha en CLAUDE.md): listener `window.addEventListener('online', flushQueue)`. Verificar que está implementado; si no, añadir.
- [ ] **14.2.5** Tests Playwright `tests/e2e/offline.test.ts`:
  - OF-01 `context.setOffline(true)`, añadir item → chip offline visible + badge "1" → `setOffline(false)` → badge desaparece + item sincronizado en otro contexto.
  - OF-02 Forzar conflicto (escribir local + escribir remoto antes de flush) → banner conflicto aparece → `/settings/sync-conflicts` lista la fila → "descartar local" la elimina.

---

#### 14.3 Server version en perfil

**Ficheros:** `apps/web/vite.config.ts`, `apps/web/src/routes/(app)/settings/+page.svelte`, `apps/web/src/lib/version.ts` (nuevo).

- [ ] **14.3.1** En `vite.config.ts`, inyectar `define: { __APP_VERSION__: JSON.stringify(<value>), __APP_COMMIT__: JSON.stringify(<value>) }`:
  - `__APP_VERSION__` = `package.json` version del root.
  - `__APP_COMMIT__` = `process.env.GIT_SHA || execSync('git rev-parse --short HEAD')` con fallback a `'dev'` si no hay git.
- [ ] **14.3.2** `apps/web/src/lib/version.ts` exporta `{ version: __APP_VERSION__, commit: __APP_COMMIT__, builtAt: __BUILD_TIME__ }`. Añadir `__BUILD_TIME__ = new Date().toISOString()` al `define`.
- [ ] **14.3.3** Declarar los tres símbolos en `apps/web/src/global.d.ts`.
- [ ] **14.3.4** Sección "Acerca de" en `/settings/+page.svelte` al final: chip pequeño `text-xs text-muted` con `v{version} · {commit} · {builtAt.slice(0,10)}`. No clicable, solo info.
- [ ] **14.3.5** Bake durante el build de Docker (`apps/web/Dockerfile`): pasar `GIT_SHA` como build arg, y desde `infra/scripts/deploy-erosi.sh` capturar `git rev-parse --short HEAD` localmente y reenviar como `--build-arg GIT_SHA=$(git rev-parse --short HEAD)`.
- [ ] **14.3.6** Test unit `apps/web/src/lib/version.test.ts`: en entorno de test (Vitest sin define) usa stubs vía `vi.stubGlobal`; verifica que `version` no sea `undefined` ni `'dev'` cuando el define está activo.

---

#### 14.Z Verificación + cierre

- [ ] **14.Z.1** `just test-all` verde. Suma esperada: +1 vitest + 3 playwright.
- [ ] **14.Z.2** Smoke manual del flujo update: build, deploy, instalar PWA en móvil, redeploy con versión bumpeada, esperar el toast (≤ 60s con `updateViaCache: 'none'`), aplicar, verificar versión nueva en `/settings`.
- [ ] **14.Z.3** Lighthouse PWA score en `https://erosi.limonia.net`: objetivo ≥ 90 (installable + offline + correct manifest).
- [ ] **14.Z.4** Documentar en `docs/history/fase-14-pwa-hardening.md`.

---

### Riesgos / notas

- **`skipWaiting` agresivo puede romper sesiones activas con cambios optimistas en vuelo**. Mitigación: el toast pide confirmación del usuario antes de `updateSW(true)`. Si el usuario ignora, el SW nuevo entra de todos modos en la siguiente navegación — aceptable.
- **`onNeedRefresh` no se dispara si Workbox no detecta cambios en el manifest**. El build debe alterar al menos un asset hasheado o el `revision` del precache no cambia. `generateSW` lo hace por defecto, pero un deploy que solo cambia env runtime no fuerza update. Documentar en el runbook.
- **Background Sync en iOS Safari sigue sin existir** (gotcha histórica). El fallback `online` listener es la única solución. No degradar la UX confiando en `sync` tag.
- **`__APP_VERSION__` y los define-symbols** rompen si el bundler hace tree-shake del fichero. Usar siempre los símbolos a través de `version.ts`, nunca inline.
- **`updateViaCache: 'none'`** en el registro del SW evita que el navegador cachee el SW mismo agresivamente. Sin esto, el toast puede tardar hasta 24h en aparecer en algunos navegadores.

### Scope explícito fuera

- Push notifications (parked en MVP; iOS 16.4+ requiere instalación a home screen — fuera).
- Resolución de conflictos con CRDT. Sigue siendo last-write-wins; solo se hace visible.
- Migración a `injectManifest` (gotcha bloquea; sigue en `generateSW`).
- Versionado semántico automático. La versión del `package.json` se bumpea a mano.
