diff --git a/docs/superpowers/plans/2026-04-18-offline-pwa.md b/docs/superpowers/plans/2026-04-18-offline-pwa.md new file mode 100644 index 0000000..95f3dc0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-offline-pwa.md @@ -0,0 +1,1982 @@ +# 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 +```