# Offline-PWA v1.1 — Design-Spec > **Stand**: 2026-04-18 — Brainstorming-Ergebnis. Vor der Plan-Erstellung vom Nutzer zu bestätigen. ## Ziel Kochwas als installierbare PWA mit vollständigem Lese-Offline-Modus. Alle Rezepte (bei ~200 erwartet: ca. 60 MB inkl. Bilder) werden automatisch lokal synchronisiert. Schreib-Aktionen bleiben online-only. Keine Backend-Änderungen. ## Design-Entscheidungen (aus Brainstorming) | Entscheidung | Gewähltes Vorgehen | |---|---| | Sync-Umfang | **Alle Rezepte + alle Bilder** (nicht nur Favoriten/Wunschliste). Einheitliches Mental-Modell "alles da". | | Installierbarkeit | **Volles PWA-Manifest + Icons** — Home-Screen-App auf Android/iOS. | | Offline-Indikator | **Dezent**, fix unten rechts als Pill. Schreib-Buttons zeigen Toast bei Fehler. | | Pre-Cache-Timing | **Im Hintergrund** nach erstem Besuch. Kein blockierender Ladescreen. Sichtbarer Fortschritt. | | Update-Strategie | **Bei jedem App-Start wenn online** — diff gegen Cache-Manifest, Delta nachladen. | | SW-Technologie | **SvelteKits eingebauter Service Worker** (`src/service-worker.ts`, `$service-worker`-Modul). Kein `vite-plugin-pwa`. | | Offline-Schreib-Queue | **Nicht Teil dieser Version**. Offline-Klicks zeigen Toast und bleiben ohne Wirkung. Komplexität verschoben auf v1.2+. | ## Architektur ### Cache-Buckets Drei Buckets, drei Strategien: 1. **`kochwas-shell-v{hash}`** — App-Shell (Build-Output: JS, CSS, Static-Icons aus `$service-worker`'s `build` + `files`). **Cache-First**. Bei Deploy neue Version → alter Cache wird in `activate` gelöscht. 2. **`kochwas-data-v1`** — Rezept-HTMLs (`/recipes/[id]`) + API-Reads (`/api/recipes/*`, `/api/wishlist`, `/api/domains`). **Stale-While-Revalidate**. Cache-Antwort sofort, Netz-Fetch parallel für nächsten Besuch. 3. **`kochwas-images-v1`** — `/images/*`. **Cache-First**. Filenames sind SHA-256-Hashes → ändert sich das Bild, ändert sich die URL, neue Einträge, alte räumt der Diff-Sync weg. ### Network-Only (nie cachen) - Alle `POST/PUT/PATCH/DELETE` Requests - `GET /api/recipes/import`, `/api/recipes/preview`, `/api/recipes/search/web` — reine Netz-Features, offline sinnfrei - `GET /api/recipes/blank` gibt es nicht (Blank ist POST) ### Pre-Cache-Flow (Initial + Update) **Initial (nach SW-Activate, einmalig)**: 1. Client postet `{ type: 'sync-start' }` an SW. 2. SW fetcht `/api/recipes/all?sort=name&limit=50&offset=N` seitenweise bis weniger als 50 Treffer kommen (Endpoint cappt aktuell auf 50 pro Request, siehe `/api/recipes/all/+server.ts`). 3. Alle IDs in Cache-Manifest-Entry schreiben (`kochwas-meta` cache, key `/cache-manifest`). 4. Für jede ID: parallel (max. 4 gleichzeitig) cachen: - `GET /recipes/{id}` → `data`-Bucket - `GET /api/recipes/{id}` → `data`-Bucket - Aus der JSON-Response `image_path` extrahieren, wenn vorhanden `GET /images/{image_path}` → `images`-Bucket 5. Nach jedem erfolgreichen Eintrag: `postMessage({ type: 'sync-progress', current, total })` an alle Clients. 6. Am Ende: `postMessage({ type: 'sync-done', lastSynced: Date.now() })`. **Update (bei jedem App-Start online)**: 1. Client postet `{ type: 'sync-check' }` an SW. 2. SW fetcht `/api/recipes/all` frisch. 3. Diff gegen Cache-Manifest: - Neue IDs → cachen wie oben (nur Delta). - Gelöschte IDs → aus `data`- und `images`-Bucket räumen. 4. Wenn Delta leer → `sync-done` mit unverändertem Zähler. **Abbruch-Resilienz**: SW hält State in Cache-Manifest; abgebrochen mittendrin → nächster Start sieht unvollständiges Manifest und holt das Fehlende nach. Idempotent. **Editierte Rezepte (gleiche ID, neuer Inhalt)**: Der Diff-Sync sieht keine Änderung (ID existiert ja). Der Refresh passiert stattdessen über Stale-While-Revalidate: wenn der User das Rezept online öffnet, liefert der Cache zuerst, der parallele Netz-Fetch aktualisiert den Cache-Eintrag. Der User sieht die Änderung also **beim übernächsten Öffnen**. Akzeptabel für eine Familien-App — wenn jemand „Salz auf 5 g" editiert, ist das nicht zeitkritisch. Bilder-Updates (neuer Image-Path durch andere Hash-URL) funktionieren automatisch: API-JSON aktualisiert sich per SWR, neue URL wird beim nächsten Bildrequest vom SW gecacht; alter Image-Cache-Entry bleibt als Orphan bis zum nächsten `diffManifest`-Lauf, der auch nach Orphan-Images schaut. **Concurrency**: 4 parallele Requests max — schont den Raspberry Pi unter Last. **Storage-Check**: Vor dem Initial-Sync `navigator.storage.estimate()`. Bei verfügbarem Quota < 100 MB → Toast: "Nicht genug Speicher für Offline-Modus". Hintergrund-Sync läuft trotzdem, bricht bei Quota-Fehler einfach ab. ### Sync-Status-Store `src/lib/client/sync-status.svelte.ts`: ```ts type SyncState = | { kind: 'idle' } | { kind: 'syncing'; current: number; total: number } | { kind: 'error'; message: string }; export const syncStatus = { state: $state({ kind: 'idle' }), lastSynced: $state(null), // Abonniert SW-Messages, dispatcht State }; ``` Gefüllt über `navigator.serviceWorker.addEventListener('message', ...)`. Persistiert `lastSynced` in localStorage (`kochwas.sw.lastSynced`). ### Online-Status-Store `src/lib/client/network.svelte.ts`: ```ts export const network = { online: $state(navigator.onLine), // initialisiert Listener auf window 'online'/'offline' }; ``` Keine heuristischen Fetches — `navigator.onLine` ist für unsere Zwecke gut genug. ### UI-Komponenten **``** — fix positioniert unten rechts, ~90×30 px Pill. Drei States: - Sync läuft: grüner Spinner + `Sync 47/200` - Offline: grauer Pill mit `Offline` - Online, alles synchron: `display: none` Tap/Klick öffnet kleine Overlay-Karte: - "Zuletzt synchronisiert: vor 3 Min · 200 Rezepte im Cache" - "Jetzt aktualisieren"-Button (triggert `sync-check`) **``** — in `+layout.svelte` am Top eingehängt. Kurze, nicht-blockierende Meldungen. Store-API: ```ts toastStore.error('Nicht verbunden'); toastStore.info('Synchronisiert — 200 Rezepte'); ``` Auto-Dismiss nach 3 s, manuell ×-klickbar. **Admin-Tab „App"** (`/admin/app`) — vierter Tab im Admin-Layout: - Install-Button: feuert das gespeicherte `beforeinstallprompt`-Event. Auf iOS (UA-Detect): Info-Text „Teilen → Zum Home-Bildschirm hinzufügen". - Sync-Status: `Synchronisiert 200/200 Rezepte (zuletzt 15:42)`. - „Jetzt aktualisieren"-Button. - „Offline-Cache leeren"-Button (destructive, zweistufig bestätigt) — für Debugging/Reset. ### Schreib-Aktionen-Verhalten Betroffene Buttons in: - `/recipes/[id]/+page.svelte`: Rating, Favorit, Wunschliste, Cooked, Kommentar, Titel, Edit-Save, Löschen, Bildschirm-Wake-Lock - `/recipes/+page.svelte` (Register): Import, Blank-Create - `/wishlist/+page.svelte`: Wunschliste-Toggle, Für-alle-entfernen - `/admin/*/+page.svelte`: Domain-CRUD, Profile-CRUD, Backup Pattern pro Klick: ```ts if (!network.online) { toastStore.error('Nicht verbunden — die Aktion speichert nicht.'); return; } // ... dann normal fetch ... ``` Alternative: Fetch versuchen, bei `TypeError: Failed to fetch` im catch toasten. Beides ist OK. Design-Entscheidung: **proaktiver Check** — klarere UX, keine falschen optimistischen UI-Updates. ### PWA-Manifest-Ergänzungen `static/manifest.webmanifest`: ```json { "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" } ] } ``` Icons werden lokal einmalig aus `static/icon.svg` gerendert (Inkscape oder `rsvg-convert`) und committed. Keine CI-Abhängigkeit. ## Dateien ### Neu - `src/service-worker.ts` — SW-Hauptdatei (install/activate/fetch/message-Handler, Pre-Cache-Orchestrator) - `src/lib/client/sync-status.svelte.ts` — Sync-Status-Store - `src/lib/client/network.svelte.ts` — Online-Status-Store - `src/lib/client/toast.svelte.ts` — Toast-Store - `src/lib/components/SyncIndicator.svelte` — bottom-right Pill + Overlay-Karte - `src/lib/components/Toast.svelte` — Toast-Renderer - `src/routes/admin/app/+page.svelte` — Admin-Tab „App" - `static/icon-192.png`, `static/icon-512.png` — PWA-Icons (einmal gerendert, committed) - `tests/integration/sw-cache-strategy.test.ts` — Unit-Tests für die Cache-Strategy-Entscheider + Diff-Logik - `tests/e2e/offline.spec.ts` — Playwright: Offline-Navigation, Sync-Indikator, Schreib-Aktion-Toast ### Geändert - `static/manifest.webmanifest` — PNG-Icons ergänzen, `purpose: "any maskable"` - `src/routes/+layout.svelte` — SW registrieren, `` + `` einbinden, Network-Store initialisieren - `src/routes/admin/+layout.svelte` — vierten Tab „App" mit Smartphone-Icon - Alle Seiten mit Schreib-Buttons — proaktiver `network.online`-Check ### Nicht angefasst - Backend (`src/lib/server/**`, `src/routes/api/**`) — reines Frontend-Feature - Datenbank-Schema - Deployment (Dockerfile, compose-Dateien) ## Test-Strategie ### Unit-Tests (vitest) - `sync-status.svelte.ts`: State-Übergänge bei Messages - `toast.svelte.ts`: Store-API, Auto-Dismiss - `sw-cache-strategy.test.ts`: - `resolveStrategy(url)` → gibt Strategy-Namen zurück (cache-first, swr, network-only) - `diffManifest(currentIds, cachedIds)` → `{ toAdd, toRemove }` - Concurrency-Queue: vier parallel, Gesamt-Reihenfolge idempotent ### E2E-Tests (Playwright, lokales Docker) - **Install + Sync**: Seite öffnen, warten bis `sync-done`, Cache-Einträge überprüfen. - **Offline-Lesen**: Netz aus (Playwright-API), Navigation `/` → `/recipes/[id]` → zurück, Rezept ist sichtbar. - **Offline-Schreiben**: Netz aus, Favorit-Toggle klicken, Toast erscheint, Herz nicht gefüllt. - **Update-Sync**: Im Browser ein neues Rezept via Register importieren, Tab neu laden, `sync-check` feuert, Rezept-ID-Liste gewachsen. - **Sync-Indikator-Zustände**: Manuell getriggert, alle drei States visuell überprüfen. ### Manuelle Tests - Android Chrome: beforeinstallprompt → Install-Button → Home-Screen-App startet - Safari iOS: Teilen → Zum Home-Bildschirm, Start der App, Offline-Navigation - Chrome DevTools → Application → Storage → Clear Site Data → Re-Load → Initial-Sync läuft durch ## Out of Scope (v1.1) Bewusst raus, mögliche v1.2-Themen: - **Background Sync für Schreib-Aktionen** — Rating/Kommentare offline speichern und später syncen. Braucht Konflikt-Resolution, schedule.sync-API, Duplikat-Erkennung. - **Push-Benachrichtigungen** — "Jemand hat ein neues Rezept hinzugefügt". Viel Infrastruktur für wenig Nutzen. - **Offline-Web-Suche** — nicht sinnvoll, braucht SearXNG. - **Partial-Sync nach Profil** — alle Rezepte bleiben synchronisiert, keine Profil-spezifische Teilmenge. ## Risiken + Mitigation | Risiko | Mitigation | |---|---| | Storage-Quota erschöpft | `navigator.storage.estimate()` vor Sync, Toast bei < 100 MB frei | | SW-Deploy: alte Clients sehen alten Cache | Cache-Name inkl. Build-Hash, `activate` räumt alte Versionen | | Alter SW blockiert Update | `skipWaiting()` + `clients.claim()` — neuer SW übernimmt sofort | | Fetch-Loop (SW ruft sich selbst) | Exakte URL-Muster-Matching, keine Wildcards auf `/api/**` | | iOS Safari vergisst Cache | Bekanntes iOS-Verhalten bei langer Inaktivität; Akzeptieren, nächster Start synct nach | | SW nur auf HTTPS oder localhost | Produktion läuft unter `https://kochwas.siegeln.net` ✓. Dev-Server läuft auf HTTP — für SW-Tests braucht's entweder `npm run build && npm run preview` (baut auf localhost, SW registrierbar) oder die lokale Docker-Compose-Prod-Variante |