# Offline-PWA v1.1 Implementation Plan > **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:** Kochwas als installierbare PWA mit vollständigem Lese-Offline-Modus — alle Rezepte + Bilder lokal gecacht, dezenter Sync-Indikator, keine Backend-Änderungen. **Architecture:** SvelteKits eingebauter Service Worker (`src/service-worker.ts`) mit drei Cache-Buckets (Shell/Daten/Bilder), Hintergrund-Pre-Cache und SWR-Updates. Frontend-Stores (`$state`-Klassen im Projekt-Muster aus `profile.svelte.ts`) für Network-Status, Sync-Status, Toasts. Neuer Admin-Tab „App". **Tech Stack:** SvelteKit 2, Svelte 5 Runes, TypeScript, `$service-worker`-Modul, Cache API, `navigator.storage`, Playwright (neu), vitest (bestehend), `sharp` (neue devDep für Icon-Rendering). Spec referenziert: `docs/superpowers/specs/2026-04-18-offline-pwa-design.md`. --- ## File Structure (Ziel-Zustand) **Neu:** - `src/service-worker.ts` — SW Einstiegspunkt (install/activate/fetch/message). Orchestriert die Sub-Module. - `src/lib/sw/cache-strategy.ts` — Reine Funktion `resolveStrategy(url)` → `'shell' | 'swr' | 'images' | 'network-only'`. Testbar ohne SW-Runtime. - `src/lib/sw/diff-manifest.ts` — Reine Funktion `diffManifest(current, cached)` → `{ toAdd, toRemove }`. Testbar. - `src/lib/client/network.svelte.ts` — `online`-Store basierend auf `navigator.onLine`. - `src/lib/client/sync-status.svelte.ts` — Sync-State + `lastSynced`, lauscht auf SW-Messages. - `src/lib/client/toast.svelte.ts` — Toast-Queue-Store. - `src/lib/client/sw-register.ts` — SW-Registrierung + Message-Bridge. - `src/lib/components/Toast.svelte` — Toast-Renderer, im `+layout.svelte`. - `src/lib/components/SyncIndicator.svelte` — Pill unten rechts + Overlay-Karte. - `src/routes/admin/app/+page.svelte` — Admin-Tab „App": Install-Button, Sync-Status, Reset. - `scripts/render-icons.mjs` — Einmal-Skript mit `sharp`, rendert aus `static/icon.svg` die PNGs. - `static/icon-192.png`, `static/icon-512.png` — Maskable-fähige PWA-Icons. - `playwright.config.ts` — E2E-Konfig. - `tests/e2e/offline.spec.ts` — E2E-Tests für PWA-Flows. - `tests/unit/cache-strategy.test.ts`, `tests/unit/diff-manifest.test.ts`, `tests/unit/toast-store.test.ts`, `tests/unit/sync-status-store.test.ts`, `tests/unit/network-store.test.ts` — Unit-Tests. **Geändert:** - `src/routes/+layout.svelte` — SW-Registrierung, `` + ``. - `src/routes/admin/+layout.svelte` — vierter Tab „App". - `src/routes/+page.svelte`, `src/routes/recipes/+page.svelte`, `src/routes/recipes/[id]/+page.svelte`, `src/routes/wishlist/+page.svelte`, `src/routes/admin/**/+page.svelte` — proaktiver Offline-Check vor Schreib-Fetches. - `static/manifest.webmanifest` — PNG-Icons + maskable. - `package.json` — `sharp`, `@playwright/test` als devDep; neue Scripts `render:icons`, `test:e2e`. --- ## Task 1: Icon-Assets rendern + Manifest erweitern **Files:** - Create: `scripts/render-icons.mjs` - Create: `static/icon-192.png` - Create: `static/icon-512.png` - Modify: `static/manifest.webmanifest` - Modify: `package.json` - [ ] **Step 1: sharp installieren** Run: `npm install --save-dev sharp` Expected: package.json updated, node_modules populated, no errors. - [ ] **Step 2: Render-Skript anlegen** Create `scripts/render-icons.mjs`: ```js // Rendert PWA-Icons aus static/icon.svg in die Größen, die Android/iOS // für Home-Screen-Icons bevorzugen. Einmal lokal ausführen und die // PNGs committen — keine CI-Abhängigkeit. import sharp from 'sharp'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, '..'); const src = await readFile(join(root, 'static/icon.svg')); for (const size of [192, 512]) { await sharp(src, { density: 400 }) .resize(size, size, { fit: 'contain', background: { r: 248, g: 250, b: 248, alpha: 1 } }) .png() .toFile(join(root, `static/icon-${size}.png`)); console.log(`wrote static/icon-${size}.png`); } ``` - [ ] **Step 3: Skript-Shortcut in package.json** Add to `"scripts"` in `package.json`: ```json "render:icons": "node scripts/render-icons.mjs" ``` - [ ] **Step 4: Icons rendern** Run: `npm run render:icons` Expected: Konsole zeigt `wrote static/icon-192.png` und `wrote static/icon-512.png`. Beide Dateien existieren. - [ ] **Step 5: Manifest erweitern** Replace entire contents of `static/manifest.webmanifest` with: ```json { "name": "Kochwas", "short_name": "Kochwas", "description": "Persönliches Rezeptbuch — lokal, einheitlich, küchentauglich", "start_url": "/", "display": "standalone", "background_color": "#f8faf8", "theme_color": "#2b6a3d", "lang": "de", "orientation": "portrait", "icons": [ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }, { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ] } ``` - [ ] **Step 6: Commit** ```bash git add package.json package-lock.json scripts/render-icons.mjs static/icon-192.png static/icon-512.png static/manifest.webmanifest git commit -m "feat(pwa): PNG-Icons 192/512 + Manifest maskable-fähig" ``` --- ## Task 2: Network-Status-Store + Test **Files:** - Create: `src/lib/client/network.svelte.ts` - Create: `tests/unit/network-store.test.ts` - [ ] **Step 1: Test schreiben** Create `tests/unit/network-store.test.ts`: ```ts import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('network store', () => { let originalOnLine: PropertyDescriptor | undefined; beforeEach(() => { originalOnLine = Object.getOwnPropertyDescriptor(globalThis.navigator, 'onLine'); Object.defineProperty(globalThis.navigator, 'onLine', { value: true, configurable: true }); }); afterEach(() => { if (originalOnLine) Object.defineProperty(globalThis.navigator, 'onLine', originalOnLine); vi.restoreAllMocks(); }); it('reflects initial navigator.onLine', async () => { const { network } = await import('../../src/lib/client/network.svelte'); expect(network.online).toBe(true); }); it('updates on offline/online events', async () => { const { network } = await import('../../src/lib/client/network.svelte'); Object.defineProperty(globalThis.navigator, 'onLine', { value: false, configurable: true }); window.dispatchEvent(new Event('offline')); expect(network.online).toBe(false); Object.defineProperty(globalThis.navigator, 'onLine', { value: true, configurable: true }); window.dispatchEvent(new Event('online')); expect(network.online).toBe(true); }); }); ``` Note: These tests need a DOM. Add `environment: 'jsdom'` to the `vitest` config OR `// @vitest-environment jsdom` comment at top of the file. Use the file-level pragma so existing node tests aren't affected. Prepend to the test file (top line): ```ts // @vitest-environment jsdom ``` - [ ] **Step 2: jsdom installieren** Run: `npm install --save-dev jsdom` Expected: package-lock updates, no errors. - [ ] **Step 3: Test ausführen — erwartet Failure (Modul fehlt)** Run: `npx vitest run tests/unit/network-store.test.ts` Expected: `Failed to load ... network.svelte` oder ähnlich. - [ ] **Step 4: Store implementieren** Create `src/lib/client/network.svelte.ts`: ```ts // Reaktiver Online-Status, basierend auf navigator.onLine + events. // Bewusst kein aktives Heuristik-Probing (Test-Fetches) — für unsere // Zwecke reicht der Browser-Status. class NetworkStore { online = $state(typeof navigator === 'undefined' ? true : navigator.onLine); init(): void { if (typeof window === 'undefined') return; window.addEventListener('online', () => (this.online = true)); window.addEventListener('offline', () => (this.online = false)); } } export const network = new NetworkStore(); ``` - [ ] **Step 5: Test-Anpassung: `init()` aufrufen** The test imports the module (which constructs the singleton), but events are only listened to after `init()`. Adjust test to call `network.init()` after import: Edit `tests/unit/network-store.test.ts` — in `beforeEach`, add: ```ts const { network } = await import('../../src/lib/client/network.svelte'); network.init(); ``` Actually restructure so import + init happen per test. Replace test file with: ```ts // @vitest-environment jsdom import { describe, it, expect, beforeEach } from 'vitest'; describe('network store', () => { beforeEach(() => { // Reset module state for each test Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }); }); it('reflects initial navigator.onLine and reacts to events', async () => { const { network } = await import('../../src/lib/client/network.svelte'); network.init(); expect(network.online).toBe(true); Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }); window.dispatchEvent(new Event('offline')); expect(network.online).toBe(false); Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }); window.dispatchEvent(new Event('online')); expect(network.online).toBe(true); }); }); ``` - [ ] **Step 6: Tests laufen lassen** Run: `npx vitest run tests/unit/network-store.test.ts` Expected: 1 passed. - [ ] **Step 7: Commit** ```bash git add src/lib/client/network.svelte.ts tests/unit/network-store.test.ts package.json package-lock.json git commit -m "feat(pwa): Online-Status-Store" ``` --- ## Task 3: Toast-Store + Komponente + Tests **Files:** - Create: `src/lib/client/toast.svelte.ts` - Create: `src/lib/components/Toast.svelte` - Create: `tests/unit/toast-store.test.ts` - Modify: `src/routes/+layout.svelte` - [ ] **Step 1: Test schreiben** Create `tests/unit/toast-store.test.ts`: ```ts import { describe, it, expect, beforeEach, vi } from 'vitest'; describe('toast store', () => { beforeEach(() => { vi.useFakeTimers(); }); it('queues toasts with auto-dismiss', async () => { const { toastStore } = await import('../../src/lib/client/toast.svelte'); toastStore.info('Hello'); expect(toastStore.toasts.length).toBe(1); expect(toastStore.toasts[0].message).toBe('Hello'); expect(toastStore.toasts[0].kind).toBe('info'); vi.advanceTimersByTime(3000); expect(toastStore.toasts.length).toBe(0); }); it('supports error kind and manual dismiss', async () => { const { toastStore } = await import('../../src/lib/client/toast.svelte'); const id = toastStore.error('Boom'); expect(toastStore.toasts[0].kind).toBe('error'); toastStore.dismiss(id); expect(toastStore.toasts.length).toBe(0); }); it('allows multiple concurrent toasts', async () => { const { toastStore } = await import('../../src/lib/client/toast.svelte'); toastStore.info('A'); toastStore.info('B'); expect(toastStore.toasts.length).toBe(2); }); }); ``` - [ ] **Step 2: Test laufen — erwartet Failure** Run: `npx vitest run tests/unit/toast-store.test.ts` Expected: Module load failure. - [ ] **Step 3: Store implementieren** Create `src/lib/client/toast.svelte.ts`: ```ts export type ToastKind = 'info' | 'error' | 'success'; export type Toast = { id: number; kind: ToastKind; message: string }; class ToastStore { toasts = $state([]); private nextId = 1; private readonly dismissMs = 3000; private push(kind: ToastKind, message: string): number { const id = this.nextId++; this.toasts = [...this.toasts, { id, kind, message }]; setTimeout(() => this.dismiss(id), this.dismissMs); return id; } info(message: string): number { return this.push('info', message); } error(message: string): number { return this.push('error', message); } success(message: string): number { return this.push('success', message); } dismiss(id: number): void { this.toasts = this.toasts.filter((t) => t.id !== id); } } export const toastStore = new ToastStore(); ``` - [ ] **Step 4: Tests laufen** Run: `npx vitest run tests/unit/toast-store.test.ts` Expected: 3 passed. Note: The toast store mutates state via `$state` runes. Each test re-imports via `await import(...)`, but vitest module cache may reuse the singleton. Reset the store's internal list explicitly at the start of each test: Adjust tests — in `beforeEach`, after `vi.useFakeTimers()`: ```ts const mod = await import('../../src/lib/client/toast.svelte'); mod.toastStore.toasts = []; ``` Re-run tests, expect all pass. - [ ] **Step 5: Toast-Komponente implementieren** Create `src/lib/components/Toast.svelte`: ```svelte
{#each toastStore.toasts as t (t.id)}
{t.message}
{/each}
``` - [ ] **Step 6: Toast in Layout einbinden** Edit `src/routes/+layout.svelte`: add at the top of ` {#if label}
{#if expanded} {/if}
{/if} ``` - [ ] **Step 2: Indicator im Layout einbinden** Edit `src/routes/+layout.svelte`: import und rendern. Das Script-Import ergänzen: ```ts import SyncIndicator from '$lib/components/SyncIndicator.svelte'; ``` Im Template, neben ``: ```svelte ``` Zusätzlich im Script den Network-Store initialisieren (einmalig client-seitig): ```ts import { onMount } from 'svelte'; import { network } from '$lib/client/network.svelte'; onMount(() => { network.init(); }); ``` (Wenn bereits ein `onMount` existiert, nur `network.init()` ergänzen.) - [ ] **Step 3: Visueller Smoketest** Run: `npm run dev` In DevTools-Konsole: ```js const mod = await import('/src/lib/client/sync-status.svelte.ts'); mod.syncStatus.handle({ type: 'sync-start', total: 10 }); mod.syncStatus.handle({ type: 'sync-progress', current: 3, total: 10 }); ``` Expected: Pill unten rechts zeigt „Sync 3/10" mit drehendem Icon. Dann: `mod.syncStatus.handle({ type: 'sync-done', lastSynced: Date.now() })` → Pill verschwindet. Offline-Test: DevTools → Network → Throttling „Offline" → Pill zeigt „Offline". - [ ] **Step 4: Commit** ```bash git add src/lib/components/SyncIndicator.svelte src/routes/+layout.svelte git commit -m "feat(pwa): SyncIndicator-Pill + Overlay-Karte" ``` --- ## Task 6: Cache-Strategy-Funktion + Tests **Files:** - Create: `src/lib/sw/cache-strategy.ts` - Create: `tests/unit/cache-strategy.test.ts` - [ ] **Step 1: Test schreiben** Create `tests/unit/cache-strategy.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { resolveStrategy } from '../../src/lib/sw/cache-strategy'; describe('resolveStrategy', () => { it('images bucket for /images/*', () => { expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images'); }); it('swr for recipe HTML pages', () => { expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('swr'); }); it('swr for recipe API reads', () => { expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('swr'); expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe('swr'); expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('swr'); }); it('network-only for write methods', () => { expect(resolveStrategy({ url: '/api/recipes/42', method: 'PATCH' })).toBe('network-only'); expect(resolveStrategy({ url: '/api/recipes/42/favorite', method: 'PUT' })).toBe('network-only'); expect(resolveStrategy({ url: '/api/wishlist', method: 'POST' })).toBe('network-only'); }); it('network-only for online-only endpoints even on GET', () => { expect(resolveStrategy({ url: '/api/recipes/import', method: 'GET' })).toBe('network-only'); expect(resolveStrategy({ url: '/api/recipes/preview?url=x', method: 'GET' })).toBe('network-only'); expect(resolveStrategy({ url: '/api/recipes/search/web?q=x', method: 'GET' })).toBe('network-only'); }); it('shell bucket for build/static assets', () => { expect(resolveStrategy({ url: '/_app/immutable/chunks/x.js', method: 'GET' })).toBe('shell'); expect(resolveStrategy({ url: '/icon-192.png', method: 'GET' })).toBe('shell'); expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell'); }); it('falls through to swr for other same-origin GETs (e.g. root page)', () => { expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('swr'); expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('swr'); }); }); ``` - [ ] **Step 2: Failure überprüfen** Run: `npx vitest run tests/unit/cache-strategy.test.ts` Expected: Module not found. - [ ] **Step 3: Implementieren** Create `src/lib/sw/cache-strategy.ts`: ```ts export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only'; export type RequestShape = { url: string; method: string }; // Reine Funktion — einziger Entscheider für "welche Strategy für diesen // Request?". Wird vom Service-Worker für jeden Fetch aufgerufen. export function resolveStrategy(req: RequestShape): CacheStrategy { // Alle Schreib-Methoden: niemals cachen. if (req.method !== 'GET' && req.method !== 'HEAD') return 'network-only'; // URL auf den Pfad reduzieren — Query-String wird für's Matching // nicht gebraucht, außer bei den online-only-Endpoints. const path = req.url.startsWith('http') ? new URL(req.url).pathname : req.url.split('?')[0]; // Explizit online-only GETs if ( path === '/api/recipes/import' || path === '/api/recipes/preview' || path.startsWith('/api/recipes/search/web') ) { return 'network-only'; } // Bilder if (path.startsWith('/images/')) return 'images'; // App-Shell: Build-Assets und bekannte statische Dateien if ( path.startsWith('/_app/') || path === '/manifest.webmanifest' || path === '/icon.svg' || path === '/icon-192.png' || path === '/icon-512.png' || path === '/favicon.ico' || path === '/robots.txt' ) { return 'shell'; } // Rest: Rezept-Seiten, API-Reads, Listen — alles SWR. return 'swr'; } ``` - [ ] **Step 4: Tests laufen** Run: `npx vitest run tests/unit/cache-strategy.test.ts` Expected: All pass. - [ ] **Step 5: Commit** ```bash git add src/lib/sw/cache-strategy.ts tests/unit/cache-strategy.test.ts git commit -m "feat(pwa): Cache-Strategy-Entscheider + Tests" ``` --- ## Task 7: Diff-Manifest-Funktion + Tests **Files:** - Create: `src/lib/sw/diff-manifest.ts` - Create: `tests/unit/diff-manifest.test.ts` - [ ] **Step 1: Test schreiben** Create `tests/unit/diff-manifest.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { diffManifest } from '../../src/lib/sw/diff-manifest'; describe('diffManifest', () => { it('detects new IDs to add', () => { const result = diffManifest([1, 2, 3, 4], [1, 2]); expect(result.toAdd.sort()).toEqual([3, 4]); expect(result.toRemove).toEqual([]); }); it('detects removed IDs', () => { const result = diffManifest([1, 2], [1, 2, 3, 4]); expect(result.toAdd).toEqual([]); expect(result.toRemove.sort()).toEqual([3, 4]); }); it('detects both add and remove in one diff', () => { const result = diffManifest([1, 3, 5], [1, 2, 3]); expect(result.toAdd).toEqual([5]); expect(result.toRemove).toEqual([2]); }); it('returns empty arrays when identical', () => { const result = diffManifest([1, 2, 3], [3, 2, 1]); expect(result.toAdd).toEqual([]); expect(result.toRemove).toEqual([]); }); it('handles empty caches (first sync)', () => { const result = diffManifest([1, 2], []); expect(result.toAdd.sort()).toEqual([1, 2]); expect(result.toRemove).toEqual([]); }); }); ``` - [ ] **Step 2: Failure überprüfen** Run: `npx vitest run tests/unit/diff-manifest.test.ts` Expected: Module not found. - [ ] **Step 3: Implementieren** Create `src/lib/sw/diff-manifest.ts`: ```ts // Vergleicht die aktuelle Rezept-ID-Liste (vom Server) mit dem, was // der Cache schon hat. Der SW nutzt das Delta, um nur Neue zu laden // und Gelöschte abzuräumen. export type ManifestDiff = { toAdd: number[]; toRemove: number[] }; export function diffManifest(currentIds: number[], cachedIds: number[]): ManifestDiff { const current = new Set(currentIds); const cached = new Set(cachedIds); const toAdd: number[] = []; const toRemove: number[] = []; for (const id of current) if (!cached.has(id)) toAdd.push(id); for (const id of cached) if (!current.has(id)) toRemove.push(id); return { toAdd, toRemove }; } ``` - [ ] **Step 4: Tests laufen** Run: `npx vitest run tests/unit/diff-manifest.test.ts` Expected: 5 passed. - [ ] **Step 5: Commit** ```bash git add src/lib/sw/diff-manifest.ts tests/unit/diff-manifest.test.ts git commit -m "feat(pwa): Cache-Manifest-Diff + Tests" ``` --- ## Task 8: Service-Worker Gerüst (install + activate + fetch) **Files:** - Create: `src/service-worker.ts` - Create: `src/lib/client/sw-register.ts` - Modify: `src/routes/+layout.svelte` - [ ] **Step 1: SW-Datei anlegen (Shell-Cache + Fetch-Dispatch)** Create `src/service-worker.ts`: ```ts /// /// /// /// import { build, files, version } from '$service-worker'; import { resolveStrategy } from '$lib/sw/cache-strategy'; declare const self: ServiceWorkerGlobalScope; const SHELL_CACHE = `kochwas-shell-${version}`; const DATA_CACHE = 'kochwas-data-v1'; const IMAGES_CACHE = 'kochwas-images-v1'; // App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt) const SHELL_ASSETS = [...build, ...files]; self.addEventListener('install', (event) => { event.waitUntil( (async () => { const cache = await caches.open(SHELL_CACHE); await cache.addAll(SHELL_ASSETS); await self.skipWaiting(); })() ); }); self.addEventListener('activate', (event) => { event.waitUntil( (async () => { // Alte Shell-Caches (vorherige Versionen) räumen const keys = await caches.keys(); await Promise.all( keys .filter((k) => k.startsWith('kochwas-shell-') && k !== SHELL_CACHE) .map((k) => caches.delete(k)) ); await self.clients.claim(); })() ); }); self.addEventListener('fetch', (event) => { const req = event.request; if (new URL(req.url).origin !== self.location.origin) return; // Cross-Origin unangetastet const strategy = resolveStrategy({ url: req.url, method: req.method }); if (strategy === 'network-only') return; if (strategy === 'shell') { event.respondWith(cacheFirst(req, SHELL_CACHE)); } else if (strategy === 'images') { event.respondWith(cacheFirst(req, IMAGES_CACHE)); } else if (strategy === 'swr') { event.respondWith(staleWhileRevalidate(req, DATA_CACHE)); } }); async function cacheFirst(req: Request, cacheName: string): Promise { const cache = await caches.open(cacheName); const hit = await cache.match(req); if (hit) return hit; const fresh = await fetch(req); if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {}); return fresh; } async function staleWhileRevalidate(req: Request, cacheName: string): Promise { const cache = await caches.open(cacheName); const hit = await cache.match(req); const fetchPromise = fetch(req) .then((res) => { if (res.ok) cache.put(req, res.clone()).catch(() => {}); return res; }) .catch(() => hit ?? Response.error()); return hit ?? fetchPromise; } export {}; ``` - [ ] **Step 2: SW-Registrierung client-seitig** Create `src/lib/client/sw-register.ts`: ```ts // Registriert den Service-Worker und verdrahtet ihn mit dem // Sync-Status-Store. Im Dev-Modus läuft Kochwas über HTTP; die // SW-API ist da nur auf localhost verfügbar. SvelteKit liefert den // SW unter /service-worker.js im Production-Build. import { syncStatus, type SWMessage } from '$lib/client/sync-status.svelte'; export async function registerServiceWorker(): Promise { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; try { await navigator.serviceWorker.register('/service-worker.js', { type: 'module' }); } catch (e) { console.warn('SW-Registrierung fehlgeschlagen', e); return; } navigator.serviceWorker.addEventListener('message', (event) => { const data = event.data as SWMessage | undefined; if (data && typeof data === 'object' && 'type' in data) { syncStatus.handle(data); } }); } ``` - [ ] **Step 3: Im Layout registrieren** Edit `src/routes/+layout.svelte` — im `

App

Einstellungen für die Installation und den Offline-Cache.

Installieren

{#if installPrompt.platform === 'ios'}

Öffne das Teilen-Menü in Safari und wähle „Zum Home-Bildschirm hinzufügen".

{:else if installPrompt.available} {:else}

Installation aktuell nicht möglich (entweder schon installiert oder Browser unterstützt es nicht).

{/if}

Offline-Synchronisation

{#if syncStatus.state.kind === 'syncing'}

Lädt gerade: {syncStatus.state.current}/{syncStatus.state.total} Rezepte.

{:else if syncStatus.state.kind === 'error'}

Fehler: {syncStatus.state.message}

{:else}

Zuletzt synchronisiert: {formatTime(syncStatus.lastSynced)}

{/if}

Cache

Nur bei Problemen: entfernt alle Offline-Daten.

``` - [ ] **Step 5: Check + Smoketest** ```bash npm run check npm run build && npm run preview ``` Open `http://localhost:4173/admin/app` — alle drei Karten sichtbar, „Installieren"-Button auf Android verfügbar (auf Desktop eventuell nicht, je nach Browser). - [ ] **Step 6: Commit** ```bash git add src/lib/client/install-prompt.svelte.ts src/routes/admin/app src/routes/admin/+layout.svelte src/routes/+layout.svelte git commit -m "feat(pwa): Admin-Tab 'App' mit Install-Button + Sync-Controls" ``` --- ## Task 12: Playwright installieren + konfigurieren **Files:** - Create: `playwright.config.ts` - Create: `tests/e2e/.gitkeep` (oder erster Test) - Modify: `package.json` - [ ] **Step 1: Playwright installieren** Run: ```bash npm install --save-dev @playwright/test npx playwright install chromium ``` Expected: Chromium-Browser downloaded, no error. - [ ] **Step 2: Config-Datei** Create `playwright.config.ts`: ```ts import { defineConfig } from '@playwright/test'; // E2E-Tests nutzen den SvelteKit-Preview-Build. `npm run build` muss // vor den Tests gelaufen sein — Playwright startet dann nur den // Preview-Server (kein Dev-Server, damit der SW registrierbar ist). export default defineConfig({ testDir: 'tests/e2e', fullyParallel: false, reporter: 'list', use: { baseURL: 'http://localhost:4173', headless: true, serviceWorkers: 'allow' }, webServer: { command: 'npm run preview', url: 'http://localhost:4173', reuseExistingServer: !process.env.CI, timeout: 30_000 } }); ``` - [ ] **Step 3: Scripts in package.json** Add: ```json "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" ``` - [ ] **Step 4: Smoke-Test schreiben** Create `tests/e2e/smoke.spec.ts`: ```ts import { test, expect } from '@playwright/test'; test('home loads', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1')).toContainText('Kochwas'); }); ``` - [ ] **Step 5: Build + E2E laufen** ```bash npm run build npm run test:e2e ``` Expected: 1 passed. - [ ] **Step 6: Commit** ```bash git add playwright.config.ts tests/e2e/smoke.spec.ts package.json package-lock.json git commit -m "chore(test): Playwright für E2E-Tests aufgesetzt" ``` --- ## Task 13: E2E — Offline-Navigation **Files:** - Create: `tests/e2e/offline.spec.ts` - [ ] **Step 1: Hilfs-Funktionen + erster Test** Create `tests/e2e/offline.spec.ts`: ```ts import { test, expect, type Page } from '@playwright/test'; // Wartet, bis der Service Worker aktiv ist und den initialen Sync // durchgelaufen hat. Wir pollen den sync-status-Store im Fenster. async function waitForSync(page: Page) { await page.waitForFunction( async () => { const r = await navigator.serviceWorker.ready; return !!r.active; }, null, { timeout: 10_000 } ); // Heuristik: warte bis keine Sync-Pill mehr sichtbar ist oder timeout await page.waitForTimeout(3000); } test('offline navigation zeigt Rezept-Detail aus dem Cache', async ({ page, context }) => { await page.goto('/'); await waitForSync(page); // Eine echte Rezept-ID suchen — voraussetzt, dass in der Test-DB mind. // ein Rezept existiert. Alternativ: testen, dass /recipes selbst lädt. await page.goto('/recipes'); const firstLink = page.locator('a[href^="/recipes/"]').first(); const href = await firstLink.getAttribute('href'); expect(href).toBeTruthy(); await context.setOffline(true); await page.goto(href!); await expect(page.locator('h1')).toBeVisible(); }); test('Offline-Schreib-Aktion zeigt Toast', async ({ page, context }) => { await page.goto('/'); await waitForSync(page); await context.setOffline(true); await page.goto('/recipes'); const firstLink = page.locator('a[href^="/recipes/"]').first(); const href = await firstLink.getAttribute('href'); await page.goto(href!); await page.getByRole('button', { name: /Favorit/ }).first().click(); await expect(page.locator('.toast.error')).toContainText(/Internet-Verbindung/); }); test('SyncIndicator zeigt Offline-Status', async ({ page, context }) => { await page.goto('/'); await waitForSync(page); await context.setOffline(true); await page.reload(); await expect(page.locator('.wrap .pill.offline')).toContainText('Offline'); }); ``` Caveats: - Diese Tests setzen voraus, dass die Dev-/Preview-Datenbank mindestens **ein Rezept** enthält. Falls leer, schlägt der erste Test fehl — in dem Fall vor dem Test-Run ein Rezept importieren oder manuell anlegen. Alternative: im Test-Setup über `/api/recipes/blank` ein Rezept anlegen. - `.toast.error` + `.wrap .pill.offline` sind Selektor-Hooks, die mit den bereits geschriebenen Komponenten zusammenpassen sollten. Falls Anpassungen nötig, CSS-Klassen beibehalten. - [ ] **Step 2: Test-DB vorbereiten (erforderlich, nicht optional)** Die Tests brauchen mindestens **ein Rezept** in der lokalen DB. Füge am Anfang von `tests/e2e/offline.spec.ts` einen `beforeAll`-Hook hinzu, der über die API ein leeres Rezept erzeugt, falls noch keines existiert: ```ts import { test as base, expect, type Page } from '@playwright/test'; const test = base.extend<{ seeded: void }>({ seeded: [ async ({ request }, use) => { const res = await request.get('/api/recipes/all?sort=name&limit=1&offset=0'); const body = await res.json(); if (body.hits.length === 0) { await request.post('/api/recipes/blank'); } await use(); }, { scope: 'worker', auto: true } ] }); ``` Die Tests nutzen dann den `test`-Export aus dieser Datei (nicht aus `@playwright/test` direkt) — dadurch läuft `seeded` automatisch einmal pro Worker. - [ ] **Step 3: E2E laufen** ```bash npm run build npm run test:e2e -- offline ``` Expected: 3 tests passed. Falls Tests flaky sind wegen SW-Timing: `waitForSync` auf konkretes Signal umstellen (z.B. per `page.evaluate` den `syncStatus`-Store abfragen). - [ ] **Step 4: Commit** ```bash git add tests/e2e/offline.spec.ts git commit -m "test(pwa): E2E für Offline-Navigation, -Schreib-Toast, -Indikator" ``` --- ## Task 14: Dokumentation + CLAUDE.md-Update **Files:** - Modify: `CLAUDE.md` - Modify: `docs/OPERATIONS.md` - Modify: `docs/ARCHITECTURE.md` - [ ] **Step 1: CLAUDE.md — neue Gotcha + Struktur** Edit `CLAUDE.md` — in der Gotcha-Tabelle ergänzen: ```markdown | **Service Worker nur ab HTTPS** | `npm run dev` liefert HTTP → SW registriert nicht. Für PWA-Tests `npm run build && npm run preview` (localhost) oder Prod-Docker. | | **Icon-Rendering** | `npm run render:icons` rendert `icon-192.png` + `icon-512.png` aus `static/icon.svg`. Nur nach SVG-Änderung erneut ausführen + committen. | ``` In „Dateien, die man typischerweise anfasst" ergänzen: - `src/service-worker.ts` — SW-Orchestrator - `src/lib/sw/` — reine Logik (Strategy, Diff) für Unit-Tests - `src/lib/client/*.svelte.ts` — Frontend-Stores (Network, Sync-Status, Toast, Install-Prompt) - [ ] **Step 2: OPERATIONS.md — PWA-Abschnitt** Edit `docs/OPERATIONS.md` — neuen Abschnitt am Ende: ```markdown ## PWA / Offline-Modus Kochwas ist eine installierbare PWA. Erkennbar an: - `static/manifest.webmanifest` (Manifest + Icons) - `src/service-worker.ts` (Cache + Sync) Caches im Browser (siehe DevTools → Application → Cache Storage): - `kochwas-shell-` — App-Shell (JS/CSS/Static-Icons) - `kochwas-data-v1` — Rezept-HTMLs + API-JSON (SWR) - `kochwas-images-v1` — Bilder (cache-first) - `kochwas-meta` — Cache-Manifest (Liste der gecachten IDs) Bei SW-Problemen Debug-Pfad: DevTools → Application → Service Workers → Unregister, dann Seite neu laden. Alternative: Admin-Tab „App" → „Offline-Cache leeren". E2E-Tests (Playwright): `npm run test:e2e`. Requires previous `npm run build`. ``` - [ ] **Step 3: ARCHITECTURE.md — kurzer Hinweis** Edit `docs/ARCHITECTURE.md` — wenn ein Frontend-Modul-Abschnitt existiert, ergänzen: ```markdown ### Service Worker `src/service-worker.ts` ist SvelteKits eingebauter SW-Slot. Er nutzt `$service-worker` (build, files, version) für den App-Shell-Cache und implementiert eigene Logik für Pre-Cache (alle Rezepte + Bilder), Delta-Sync beim App-Start und die drei Cache-Strategien (Shell=cache-first, Daten=SWR, Bilder=cache-first). Reine Logik-Einheiten (testbar): `src/lib/sw/cache-strategy.ts`, `src/lib/sw/diff-manifest.ts`. Client-Stores: `src/lib/client/{network,sync-status,toast,install-prompt}.svelte.ts`. ``` (Falls ARCHITECTURE.md keinen solchen Abschnitt hat, einfach an passender Stelle einfügen.) - [ ] **Step 4: Commit** ```bash git add CLAUDE.md docs/OPERATIONS.md docs/ARCHITECTURE.md git commit -m "docs(pwa): CLAUDE.md, OPERATIONS, ARCHITECTURE aktualisiert" ``` --- ## Task 15: End-to-End Manual-Check + Push - [ ] **Step 1: Komplett-Build** ```bash npm run check npm test npm run build npm run test:e2e ``` Expected: Alles grün — 0 Errors, alle Unit-Tests + E2E passen. - [ ] **Step 2: Preview — PWA-Full-Check** ```bash npm run preview ``` - Öffne `http://localhost:4173` in Chrome - DevTools → Lighthouse → Kategorie „Progressive Web App" → Report: sollte installability OK sein - Application → Service Workers: `activated and is running` - Application → Cache Storage: alle vier Caches gefüllt - Admin → App: Install-Button sichtbar (Chrome Desktop), Sync-Status zeigt Zeit + Anzahl - [ ] **Step 3: Docker-Compose-Prod (reale Umgebung)** ```bash docker compose -f docker-compose.prod.yml up --build ``` Öffne in einem echten Smartphone-Browser `https://.siegeln.net` — `kochwas.siegeln.net` wenn schon deployed. Alternativ: Port 443 aus dem Netz, oder lokal `docker compose up` + manifest via localhost testen. - Mobile Chrome: beforeinstallprompt → Install-Button funktioniert → App auf Home-Screen → App öffnen → standalone-Modus ohne Browser-UI. - Im Flugmodus: App öffnen, zu Rezepten navigieren, lesen. - Online wieder: Sync läuft im Hintergrund, neue Rezepte kommen dazu. - [ ] **Step 4: Final-Push** ```bash git push ``` - [ ] **Step 5: Version-Bump & Release-Tag** Wenn v1.1 als Release markiert werden soll: ```bash git tag -a v1.1.0 -m "Offline-PWA: alle Rezepte + Bilder lokal, installierbar, SWR-Updates" git push --tags ```