# Actualización segura de la PWA — plan de implementación

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** que un SW nuevo espere en `waiting` hasta que no haya nada en curso, y se aplique solo sin token o cuando el usuario toque "Actualizar".

**Architecture:** el SW deja de hacer `skipWaiting()` en `install`. Un contador de módulo (`composables/operacionesEnCurso.js`) registra envíos, espera del GPS y login a medio escribir, con 8 s de reposo tras terminar. Un coordinador (`services/actualizacion.js`) reemplaza el `registerSW` de `main.js`: decide cuándo mandar `SKIP_WAITING` y recarga en `controllerchange` solo cuando está libre. Un aviso en `PwaHeader`/`DashboardHeader` ofrece la actualización con sesión viva.

**Tech Stack:** Vue 3 + Pinia, vite-plugin-pwa 1.3 (`registerType: 'prompt'`, `injectManifest`), workbox-window 7.4, Playwright (chromium).

**Spec:** `docs/superpowers/specs/2026-10-01-actualizacion-segura-design.md`.

> **Desvíos durante la ejecución** (el código final es el de la rama, no el de este plan):
> - `aplicar()` no hace nada sin registración y `onRegisteredSW` llama a `intentar()`: el código de la Task 3 descartaba la versión que ya esperaba al cargar (workbox-window la avisa antes de entregar la registración). Lo encontró un paso E2E nuevo, que falla sin el arreglo.
> - `empezarOperacion({ reposoMs })`: `logout` (en `stores/auth.js`, que borra el token antes de vaciar la caché) y `retenerMientras` cuentan sin reposo.
> - En Inicio el aviso va dentro de `DashboardHeader`, no en `HomeView` (ver Task 4).
> - `vigilarRegistro(r)`: además del aviso del plugin, el coordinador mira la registración (`waiting` al registrar, `updatefound` → `statechange`), por si la versión todavía se instalaba cuando la página registró el SW. Sin SW activo no cuenta (primera instalación).
> - Revisión independiente (2026-10-02): el camino automático manda `SKIP_WAITING` con `soloSiUnica` (el SW solo se activa si la ventana que lo pidió es la única de `/pwa`, mirando hasta 5 s) y solo en rutas `meta.guest`; con sesión exige señal y perderla anula lo pedido; más escrituras cuentan como operación; tope de 10 s para `aplicando`; `update().catch`; botón de 44 px. El código final es el de la rama (spec §1-§4).

**Entorno:** worktree `/home/jredondo/credify-sw` (rama `fix/pwa-actualizacion-segura`). Shell y git dentro de WSL con `wsl -d Ubuntu --exec bash -lc '…'` (sin `--exec`, el shell de afuera expande `$`). E2E SOLO con `/home/jredondo/e2e-sw.sh` (aborta si el 8123 está ocupado; no matar servidores ajenos). Login de cobrador: 5/min compartido con otras sesiones.

---

## Mapa de archivos

| Archivo | Qué |
|---|---|
| `tests/e2e/actualizacion-sw.spec.js` (nuevo) | E2E con `serviceWorkers: 'allow'`; simula deploys reescribiendo `public/pwa-sw.js` |
| `resources/js/pwa/composables/operacionesEnCurso.js` (nuevo) | contador de lo que no se puede cortar |
| `resources/js/pwa/services/actualizacion.js` (nuevo) | registro del SW y decisión de aplicar/recargar |
| `resources/js/pwa/components/ui/AvisoVersionNueva.vue` (nuevo) | franja "Hay una versión nueva · Actualizar" |
| `resources/js/pwa/pwa-sw.js` | `install` sin `skipWaiting()` |
| `resources/js/pwa/main.js` | `iniciarActualizaciones()` en vez del bloque de `registerSW` |
| `resources/js/pwa/router/index.js` | `router.onError` → `recargarCuandoSeaSeguro` |
| `resources/js/pwa/stores/sync.js` | `enviarOEncolar` envuelto |
| `PaymentView`, `NoPaymentModal`, `ExpenseCreateView`, `CreditCreateView`, `PartnerTransactionView`, `ClientCreateView`, `ClientEditView`, `LoginView` | submit envuelto; login retiene con texto |
| `components/layout/PwaHeader.vue`, `components/home/DashboardHeader.vue` | `<AvisoVersionNueva />` |
| `CHANGELOG.md`, spec | registro y estado |

---

### Task 1: E2E que falla primero

**Files:** Create `tests/e2e/actualizacion-sw.spec.js`

- [ ] **Step 1: escribir el spec**

```js
import { test, expect } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { randomInt } from 'node:crypto';

const creds = JSON.parse(
    readFileSync(path.join(process.cwd(), 'tests/e2e/.auth/creds.json'), 'utf8')
);

const DB_NAME = 'CredifyGoPWA';

// El SW compilado. Está en .gitignore y lo sirven estático tanto `artisan serve`
// como el nginx del CI: reescribirlo es publicar una versión nueva. Playwright no
// intercepta la descarga del script del SW (`context.route` no la ve), así que no
// hay otra forma de simular un deploy.
const SW = path.join(process.cwd(), 'public/pwa-sw.js');
// Lo que se agrega al SW para hacerlo "otra versión". Si una corrida murió sin
// restaurar, se corta desde acá al leer el original.
const MARCA = '\n;// e2e: versión simulada\n';

function leerSwOriginal() {
    return readFileSync(SW, 'utf8').split(MARCA)[0];
}

/** Publica una "versión nueva": el mismo SW más un manejador que contesta su marca. */
function publicarVersion(original, version) {
    writeFileSync(
        SW,
        `${original}${MARCA}self.addEventListener('message', (e) => {
            if (e.data?.type === 'E2E_VERSION') e.ports[0]?.postMessage(${JSON.stringify(version)})
        })\n`
    );
}

// ── Helpers: los mismos de senal-debil.spec.js ──────────────────────────────

function idb(page, fn, arg) {
    return page.evaluate(
        ([body, dbName, payload]) => {
            // eslint-disable-next-line no-new-func
            const run = new Function('db', 'payload', `return (${body})(db, payload)`);

            return new Promise((resolve, reject) => {
                const req = indexedDB.open(dbName);
                req.onerror = () => reject(req.error);
                req.onsuccess = () => {
                    Promise.resolve(run(req.result, payload))
                        .then(resolve, reject)
                        .finally(() => req.result.close());
                };
            });
        },
        [fn.toString(), DB_NAME, arg ?? null]
    );
}

const contar = (db, store) =>
    new Promise((resolve, reject) => {
        const req = db.transaction(store, 'readonly').objectStore(store).count();
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });

/** Un crédito activo con cuota pendiente, leído de la caché de la app. */
const creditoConCuota = async (db) => {
    const leer = (s) =>
        new Promise((ok, mal) => {
            const r = db.transaction(s, 'readonly').objectStore(s).getAll();
            r.onsuccess = () => ok(r.result);
            r.onerror = () => mal(r.error);
        });
    const [creditos, cuotas] = await Promise.all([leer('credits'), leer('installments')]);
    const elegibles = creditos.filter(
        (c) =>
            ['active', 'delayed', 'overdue'].includes(c.status) &&
            c.remaining_balance > 10_000 &&
            cuotas.some((i) => i.credit_id === c.id && ['pending', 'partial_paid'].includes(i.status))
    );
    return elegibles.length ? elegibles[Math.floor(Math.random() * elegibles.length)] : null;
};

async function loginAsCollector(page) {
    const cred = creds.find((c) => c.role === 'collector');
    expect(cred, 'sin credencial para el rol collector').toBeTruthy();

    await page.goto('/pwa/login');
    await page.getByPlaceholder('Correo electrónico o teléfono').fill(cred.email);
    await page.getByPlaceholder('Contraseña').fill(cred.password);
    await page.getByRole('button', { name: 'Entrar' }).click();
    await expect(page).toHaveURL(/\/pwa\/home/, { timeout: 15_000 });
}

function irA(page, ruta) {
    return page.evaluate(
        (r) => document.querySelector('[data-v-app]').__vue_app__.config.globalProperties.$router.push(r),
        ruta
    );
}

/** Entre $1,01 y $1,99 (x1.000 en modo simplificado), distinto en cada corrida. */
const montoAlAzar = () => (1 + randomInt(1, 100) / 100).toFixed(2);

// ── Helpers propios ─────────────────────────────────────────────────────────

/** Deja una marca en la página: si sigue, no hubo recarga. */
function marcarPagina(page) {
    return page.evaluate(() => {
        window.__sinRecargar = true;
    });
}

/** Durante una recarga el contexto se destruye y `evaluate` falla: cuenta como recargada. */
function sigueSinRecargar(page) {
    return page.evaluate(() => window.__sinRecargar === true).catch(() => false);
}

/** La marca del SW que controla la página; null es el del build (no contesta). */
function versionActiva(page) {
    return page
        .evaluate(
            () =>
                new Promise((resolve) => {
                    const sw = navigator.serviceWorker.controller;
                    if (!sw) return resolve(null);
                    const canal = new MessageChannel();
                    canal.port1.onmessage = (e) => resolve(e.data ?? null);
                    sw.postMessage({ type: 'E2E_VERSION' }, [canal.port2]);
                    setTimeout(() => resolve(null), 1000);
                })
        )
        .catch(() => null);
}

function esperarControlDelSw(page) {
    return expect
        .poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller)).catch(() => false), {
            message: 'el SW del build debe instalarse y controlar la página',
            timeout: 30_000,
        })
        .toBe(true);
}

/**
 * Lo que hace la app al volver a primer plano (`registration.update()`), y espera
 * a ver qué pasó con la versión nueva: debe quedar ESPERANDO.
 */
async function buscarVersionNueva(page) {
    await page.evaluate(async () => {
        const r = await navigator.serviceWorker.getRegistration();
        await r.update();
    });

    const estado = () =>
        page
            .evaluate(async () => {
                if (window.__sinRecargar !== true) return 'recargó';
                const r = await navigator.serviceWorker.getRegistration();
                if (r?.waiting) return 'esperando';
                return r?.installing ? 'instalando' : 'sin versión nueva o ya activa';
            })
            .catch(() => 'recargó');

    await expect
        .poll(estado, {
            message: 'la versión nueva debe quedar esperando (sin skipWaiting en install) y sin recargar la página',
            timeout: 30_000,
        })
        .toBe('esperando');
}

test.describe('actualización de la PWA: la versión nueva no corta un envío', () => {
    // Lógica, no viewport; y cada corrida gasta un login (límite 5/min).
    test.skip(({ isMobile }) => Boolean(isMobile), 'lógica de la actualización: no depende del viewport');

    test.use({
        // El resto de la suite bloquea el SW; este spec es justamente sobre él.
        serviceWorkers: 'allow',
        timezoneId: 'America/Bogota',
    });

    let original;
    test.beforeAll(() => {
        original = leerSwOriginal();
    });
    test.afterAll(() => {
        if (original) writeFileSync(SW, original);
    });

    // Un solo test con pasos: un solo login para todo el recorrido.
    test('con sesión espera y avisa; sin sesión se aplica sola', async ({ page, context }) => {
        test.setTimeout(150_000);

        // GPS colgado, como un permiso sin contestar: la pantalla de pago espera los
        // 6 s del tope antes de armar el cobro. Es el tramo en que la fila todavía no existe.
        await context.addInitScript(() => {
            const nada = () => {};
            Object.defineProperty(navigator, 'geolocation', {
                configurable: true,
                value: { getCurrentPosition: nada, watchPosition: () => 0, clearWatch: nada },
            });
        });

        // Como en producción: la app arranca ya controlada por un SW.
        await page.goto('/pwa/login');
        await esperarControlDelSw(page);
        await loginAsCollector(page);
        await expect
            .poll(() => idb(page, contar, 'credits'), { timeout: 20_000 })
            .toBeGreaterThan(0);

        await test.step('con sesión, la versión nueva espera y avisa', async () => {
            await marcarPagina(page);
            publicarVersion(original, 'v2');
            await buscarVersionNueva(page);

            await expect(page.getByText('Hay una versión nueva')).toBeVisible();
            expect(await versionActiva(page), 'sigue mandando el SW del build').toBeNull();
        });

        await test.step('tocada durante un cobro, se aplica después de ver el recibo', async () => {
            const credito = await idb(page, creditoConCuota);
            expect(credito, 'la demo no tiene un crédito con cuota pendiente').toBeTruthy();

            const confirmar = page.getByRole('button', { name: 'Confirmar Pago' });
            await irA(page, `/pwa/credits/${credito.id}/pay`);
            await expect(confirmar).toBeVisible({ timeout: 15_000 });
            await page.locator('input[type="number"]').first().fill(montoAlAzar());
            await expect(confirmar).toBeEnabled();
            await confirmar.click();

            // En plena espera del GPS: el cobro todavía no está en IndexedDB.
            await expect(page.getByText('Procesando...')).toBeVisible();
            await page.getByRole('button', { name: 'Actualizar', exact: true }).click();
            await expect(page.getByText('Se actualiza al terminar el envío')).toBeVisible();
            expect(await sigueSinRecargar(page), 'no se recarga en pleno envío').toBe(true);

            // El recibo se ve antes de la recarga (el envío cuenta 8 s más tras terminar).
            await expect(page.getByText('Pago Registrado', { exact: true })).toBeVisible({ timeout: 20_000 });
            expect(await sigueSinRecargar(page), 'el recibo no se borra al aparecer').toBe(true);

            await expect
                .poll(() => sigueSinRecargar(page), { message: 'terminado el envío, se aplica', timeout: 20_000 })
                .toBe(false);
            await expect.poll(() => versionActiva(page), { timeout: 15_000 }).toBe('v2');
        });

        await test.step('sin sesión se aplica sola, salvo con el login a medio escribir', async () => {
            // Sin token en memoria: la recarga ya no le cuesta nada al usuario.
            await page.goto('/pwa/login');
            const usuario = page.getByPlaceholder('Correo electrónico o teléfono');
            await expect(usuario).toBeVisible();
            await usuario.fill('alguien');
            await marcarPagina(page);

            publicarVersion(original, 'v3');
            await buscarVersionNueva(page);
            // Negativo: con texto en el formulario no debe recargar.
            await page.waitForTimeout(1_500);
            expect(await sigueSinRecargar(page), 'no borra lo que el usuario está escribiendo').toBe(true);

            await usuario.fill('');
            await expect
                .poll(() => sigueSinRecargar(page), { message: 'vacío el login, se aplica sola', timeout: 20_000 })
                .toBe(false);
            await expect.poll(() => versionActiva(page), { timeout: 15_000 }).toBe('v3');
        });
    });
});
```

- [ ] **Step 2: correr y ver que falla**

Run: `wsl -d Ubuntu --exec bash -lc '/home/jredondo/e2e-sw.sh tests/e2e/actualizacion-sw.spec.js'`
Expected: FAIL en "con sesión, la versión nueva espera y avisa" — el poll ve "sin versión nueva o ya activa" (o "recargó"), no "esperando".

- [ ] **Step 3: commit**

`git add tests/e2e/actualizacion-sw.spec.js` y commit `test(e2e): la versión nueva de la PWA espera a que no haya nada en curso (falla primero)`.

---

### Task 2: lo que no se puede cortar

**Files:** Create `resources/js/pwa/composables/operacionesEnCurso.js`; Modify `stores/sync.js`, las 8 pantallas.

- [ ] **Step 1: el contador**

```js
import { computed, onUnmounted, ref, watch } from 'vue'

/**
 * Lo que la app está haciendo y una recarga cortaría: un envío, la espera del GPS
 * antes de armar un cobro, el login a medio escribir. La actualización de la PWA
 * (services/actualizacion.js) espera a que esto llegue a cero.
 *
 * Contador de módulo, como useToast: sirve también fuera de componentes (stores).
 */

// Una operación sigue contando un rato después de terminar: termina justo cuando
// aparece su resultado (el recibo, "Gasto registrado", un aviso de 8 s de "puede
// que sí haya quedado"). Recargar ahí se lo borra, y quien no vio la confirmación
// lo vuelve a registrar. 8 s es lo que duran esos avisos.
export const REPOSO_MS = 8000

const enCurso = ref(0)

export const hayOperacionEnCurso = computed(() => enCurso.value > 0)

/** Cuenta una operación hasta llamar a la función devuelta (que es idempotente). */
export function empezarOperacion() {
    enCurso.value++
    let terminada = false
    return () => {
        if (terminada) return
        terminada = true
        setTimeout(() => {
            enCurso.value--
        }, REPOSO_MS)
    }
}

/**
 * Envuelve una función async: cuenta desde la llamada hasta que su promesa termina,
 * resuelva o rechace, aunque la pantalla se desmonte antes (el cobrador puede volver
 * atrás durante "Procesando..." y el envío sigue).
 */
export function conOperacionEnCurso(fn) {
    return async function (...args) {
        const terminar = empezarOperacion()
        try {
            return await fn.apply(this, args)
        } finally {
            terminar()
        }
    }
}

/** Para un componente: retiene mientras `fuente()` sea verdadera y suelta al desmontarse. */
export function retenerMientras(fuente) {
    let terminar = null
    const parar = watch(
        () => Boolean(fuente()),
        (activa) => {
            if (activa && !terminar) terminar = empezarOperacion()
            if (!activa && terminar) {
                terminar()
                terminar = null
            }
        },
        { immediate: true }
    )
    onUnmounted(() => {
        parar()
        terminar?.()
        terminar = null
    })
}
```

- [ ] **Step 2: envolver**

Mismo patrón en cada sitio: la declaración `async function nombre(...) {` pasa a `const nombre = conOperacionEnCurso(async function nombre(...) {` y su `}` de cierre a `})`; el cuerpo no cambia. Import: `import { conOperacionEnCurso } from '<ruta>/composables/operacionesEnCurso'`.

| Archivo | Función |
|---|---|
| `stores/sync.js` | `enviarOEncolar(tipo, datos)` (import `'../composables/operacionesEnCurso'`) |
| `views/PaymentView.vue` | `submitPayment()` |
| `components/collections/NoPaymentModal.vue` | `submit()` (import `'../../composables/operacionesEnCurso'`) |
| `views/ExpenseCreateView.vue` | `submit()` |
| `views/CreditCreateView.vue` | `handleSubmit()` |
| `views/PartnerTransactionView.vue` | `submit()` |
| `views/ClientCreateView.vue` | `submitClient(payload)` |
| `views/ClientEditView.vue` | `submitEdit(payload)` |
| `views/LoginView.vue` | `handleLogin()` |

Antes de cada cambio, `grep -n "<nombre>"` en el archivo: si alguien la llama ANTES de su definición durante el setup (no solo desde el template o un evento), el `const` no está elevado y rompe; en ese caso mover la definición arriba.

En `LoginView.vue`, además (import `retenerMientras` del mismo módulo), justo después de declarar `identifier` y `password`:

```js
// Con el login a medio escribir, una versión nueva no recarga (le borraría lo
// escrito): se ofrece con el aviso después de entrar.
retenerMientras(() => identifier.value || password.value)
```

- [ ] **Step 3: build**

Run: `wsl -d Ubuntu --exec bash -lc 'cd /home/jredondo/credify-sw && VITE_SENTRY_DSN= npm run build 2>&1 | tail -3'` → "files generated".

- [ ] **Step 4: commit** `feat(pwa): registro de lo que una recarga cortaría (envíos, GPS, login a medio escribir)`.

---

### Task 3: el SW espera y la app decide

**Files:** Create `resources/js/pwa/services/actualizacion.js`; Modify `pwa-sw.js`, `main.js`, `router/index.js`.

- [ ] **Step 1: el coordinador**

```js
import { computed, reactive, readonly, watch } from 'vue'
import { registerSW } from 'virtual:pwa-register'
import { useAuthStore } from '../stores/auth'
import { useSyncStore } from '../stores/sync'
import { hayOperacionEnCurso } from '../composables/operacionesEnCurso'

/**
 * Cuándo se aplica una versión nueva de la PWA (spec 2026-10-01-actualizacion-segura).
 *
 * El SW nuevo queda en `waiting` (pwa-sw.js ya no hace skipWaiting en install) y el
 * viejo sigue sirviendo su precache, con los chunks de la página abierta. Se aplica:
 *   - solo, si no hay token en memoria: hay que entrar de todos modos;
 *   - si el usuario tocó "Actualizar" (AvisoVersionNueva);
 * y siempre cuando no hay nada en curso. Toda recarga cuesta un login (el token
 * vive solo en memoria), así que con sesión viva nunca se aplica sin preguntar.
 */

const estado = reactive({ hayVersionNueva: false, pedida: false })

/** Estado para la UI (solo lectura). */
export const actualizacion = readonly(estado)

/** Nada en curso: ni envíos (con su reposo), ni una ronda de sincronización. */
export const libre = computed(() => !hayOperacionEnCurso.value && !useSyncStore().syncing)

let registro = null
let updateSW = () => {}
let aplicando = false
let recarga = null
let recargando = false

function intentar() {
    if (!estado.hayVersionNueva || aplicando || !libre.value) return
    if (!useAuthStore().token || estado.pedida) aplicar()
}

function aplicar() {
    if (!registro?.waiting) {
        // El SW que esperaba ya no está (otro más nuevo lo dejó redundant): nada que aplicar.
        estado.hayVersionNueva = false
        estado.pedida = false
        return
    }
    aplicando = true
    // SKIP_WAITING al SW en espera. La recarga la hace `controllerchange`.
    updateSW()
}

function intentarRecarga() {
    if (!recarga || recargando || !libre.value) return
    recargando = true
    if (recarga.destino) window.location.assign(recarga.destino)
    else window.location.reload()
}

/**
 * Recarga en cuanto no haya nada en curso, una sola vez. `destino`: a dónde ir
 * (router.onError pasa la ruta que no pudo cargar); sin él, la página actual.
 */
export function recargarCuandoSeaSeguro(destino = null) {
    recarga = { destino: destino ?? recarga?.destino ?? null }
    intentarRecarga()
}

/** El usuario tocó "Actualizar": se aplica en cuanto no haya nada en curso. */
export function pedirActualizacion() {
    estado.pedida = true
    intentar()
}

/** Se llama una vez desde main.js, con Pinia ya instalado. */
export function iniciarActualizaciones() {
    const authStore = useAuthStore()

    watch(
        () => [libre.value, authStore.token, estado.hayVersionNueva, estado.pedida],
        () => {
            intentar()
            intentarRecarga()
        }
    )

    if (!('serviceWorker' in navigator)) return

    // La recarga la decide el cambio de controlador, no el plugin: este solo recarga
    // si la página ya tenía SW al registrarse, y una que arrancó sin él (primera
    // instalación, recarga forzada) quedaría con código viejo bajo el SW nuevo.
    // Sin controlador anterior (el clients.claim() de la primera instalación) no
    // hay versión vieja que cambiar.
    let controlador = navigator.serviceWorker.controller
    navigator.serviceWorker.addEventListener('controllerchange', () => {
        const anterior = controlador
        controlador = navigator.serviceWorker.controller
        if (anterior || aplicando) recargarCuandoSeaSeguro()
    })

    updateSW = registerSW({
        // Hay un SW en `waiting`. El plugin lo avisa al encontrarlo por update(),
        // al cargar, y si ya esperaba antes de registrar (la carga completa tras un
        // 401). Puede llegar dos veces.
        onNeedRefresh() {
            estado.hayVersionNueva = true
            intentar()
        },
        // Vacío a propósito: ver `controllerchange` arriba.
        onNeedReload() {},
        onOfflineReady() {
            console.log('[PWA] App lista para uso offline')
        },
        onRegisteredSW(_url, registration) {
            registro = registration ?? null
            console.log('[PWA] Service Worker registrado:', registration?.scope)
        },
        onRegisterError(error) {
            console.error('[PWA] Error registrando Service Worker:', error)
        },
    })

    // iOS no re-verifica el SW al reabrir desde segundo plano: al volver a primer
    // plano se fuerza UNA verificación. Si hay versión nueva, queda en espera y
    // onNeedRefresh decide. Sin setInterval: update() re-descarga el SW cada vez.
    document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'visible') registro?.update()
    })
}
```

- [ ] **Step 2: `pwa-sw.js`** — reemplazar el bloque LIFECYCLE de `install`:

```js
// ============================================================================
// LIFECYCLE — la versión nueva ESPERA
// ============================================================================
// Sin skipWaiting() en install: el SW nuevo queda en `waiting` y el viejo sigue
// sirviendo su precache completo, con los chunks de la página abierta (al
// activarse, el nuevo los borra). La app manda SKIP_WAITING cuando no hay nada en
// curso (services/actualizacion.js); con la app cerrada del todo, el navegador lo
// activa solo. La primera instalación no espera: no hay SW activo.
self.addEventListener('install', () => {
    console.log(`[SW] Installing v${SW_BUILD_INFO.version} — espera a que la app lo active`)
})
```

El `activate` con `clients.claim()` y el `message` `SKIP_WAITING` quedan igual.

- [ ] **Step 3: `main.js`** — quitar `import { registerSW } from 'virtual:pwa-register'`, el bloque `let swRegistration…registerSW({…})` y el `visibilitychange` del SW; agregar `import { iniciarActualizaciones } from './services/actualizacion'` y, donde estaba el bloque (después de `app.mount`):

```js
// Service Worker y actualizaciones: la versión nueva espera a que no haya nada en
// curso y solo se aplica sola sin token (services/actualizacion.js).
iniciarActualizaciones()
```

- [ ] **Step 4: `router/index.js`** — `import { recargarCuandoSeaSeguro } from '../services/actualizacion'` y en `router.onError`, la rama con señal:

```js
    } else {
        // Con señal: chunk de otra versión (SW bloqueado o no soportado, u otra
        // pestaña que aplicó la versión nueva). Recarga completa, pero sin cortar
        // un envío en curso.
        recargarCuandoSeaSeguro(to?.fullPath ?? '/pwa/home')
    }
```

- [ ] **Step 5: build** → "files generated". **Commit** `fix(pwa): la versión nueva espera en waiting y la app decide cuándo aplicarla`.

---

### Task 4: el aviso

**Files:** Create `components/ui/AvisoVersionNueva.vue`; Modify `components/layout/PwaHeader.vue`, `components/home/DashboardHeader.vue`.

- [ ] **Step 1: el componente**

```vue
<template>
    <!--
      Versión nueva esperando, con sesión viva: recargar cuesta un login, así que se
      ofrece y el usuario decide. Sin token se aplica sola (services/actualizacion.js).
      Va dentro del flujo de la página (no flota): no tapa cabecera, botones ni FAB.
    -->
    <div v-if="visible" role="status"
         class="mt-3 flex items-center gap-3 rounded-2xl px-4 py-2.5 border bg-emerald-50 border-emerald-200 text-emerald-900 dark:bg-emerald-500/10 dark:border-emerald-400/20 dark:text-emerald-100">
        <div class="flex-1 min-w-0">
            <p class="text-sm font-semibold">Hay una versión nueva</p>
            <p class="text-xs opacity-80">{{ detalle }}</p>
        </div>
        <button type="button" :disabled="actualizacion.pedida" @click="pedirActualizacion"
                class="shrink-0 rounded-xl px-3 py-1.5 text-sm font-semibold text-white bg-emerald-600 disabled:opacity-50 card-press">
            Actualizar
        </button>
    </div>
</template>

<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { actualizacion, libre, pedirActualizacion } from '../../services/actualizacion'

const authStore = useAuthStore()

// Sin señal no se podría volver a entrar: no se ofrece.
const enLinea = ref(navigator.onLine)
const alCambiarRed = () => { enLinea.value = navigator.onLine }
onMounted(() => {
    window.addEventListener('online', alCambiarRed)
    window.addEventListener('offline', alCambiarRed)
})
onUnmounted(() => {
    window.removeEventListener('online', alCambiarRed)
    window.removeEventListener('offline', alCambiarRed)
})

const visible = computed(() => actualizacion.hayVersionNueva && Boolean(authStore.token) && enLinea.value)

const detalle = computed(() => {
    if (!actualizacion.pedida) return 'Al actualizar vuelves a iniciar sesión.'
    return libre.value ? 'Actualizando…' : 'Se actualiza al terminar el envío'
})
</script>
```

- [ ] **Step 2: ubicarlo** — en `PwaHeader.vue`, después de `<slot />`: `<AvisoVersionNueva />` (import `'../ui/AvisoVersionNueva.vue'`). En `components/home/DashboardHeader.vue` (la cabecera propia de Inicio), bajo el saludo: `<div class="relative z-10"><AvisoVersionNueva /></div>`. Entre la cabecera y las tarjetas no: estas se meten bajo el borde con margen negativo y lo taparían (visto en captura).

- [ ] **Step 3: correr el E2E nuevo** → PASS. Si falla, leer el motivo (no tocar el test para que pase sin entender por qué).

- [ ] **Step 4: commit** `feat(pwa): aviso "Hay una versión nueva" con sesión viva`.

---

### Task 5: verificación completa, docs y PR

- [ ] Suite E2E chromium completa con `/home/jredondo/e2e-sw.sh` (espaciar corridas: login compartido). Esperado: todo verde, incluido el nuevo.
- [ ] `VITE_SENTRY_DSN= npm run build`, `./vendor/bin/phpstan analyse --level=5 app/`, `./vendor/bin/pint --test`, `php artisan test --compact` (este cambio no toca PHP; el CI los corre igual).
- [ ] `CHANGELOG.md` (entrada en "Corregido"), estado del spec → "implementado; falta PR y despliegue".
- [ ] Commit, push, PR con body por archivo (`--body-file`), CI verde (`gh pr checks`), aviso al coordinador por SendMessage. No mergear ni desplegar.
