Ergebnis des Brainstormings. Entscheidungen: - Alle Rezepte + Bilder synchronisieren (~60 MB, ~200 Rezepte) - SvelteKits eingebauter Service Worker, keine externe PWA-Abhängigkeit - Hintergrund-Pre-Cache ohne Blocker, sichtbarer Fortschritt im dezenten Sync-Indikator unten rechts - Stale-While-Revalidate für Rezept-Daten, Cache-First für Shell+Bilder - Schreib-Aktionen offline: proaktiver Check + Toast, keine Queue - Neuer Admin-Tab "App" für Install-Button, Sync-Status, Reset - Unit-Tests für Cache-Strategy/Diff, Playwright-E2E für Offline-Flows Bereit für Nutzer-Review und anschließende Plan-Erstellung. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
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:
-
kochwas-shell-v{hash}— App-Shell (Build-Output: JS, CSS, Static-Icons aus$service-worker'sbuild+files). Cache-First. Bei Deploy neue Version → alter Cache wird inactivategelöscht. -
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. -
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/DELETERequests GET /api/recipes/import,/api/recipes/preview,/api/recipes/search/web— reine Netz-Features, offline sinnfreiGET /api/recipes/blankgibt es nicht (Blank ist POST)
Pre-Cache-Flow (Initial + Update)
Initial (nach SW-Activate, einmalig):
- Client postet
{ type: 'sync-start' }an SW. - SW fetcht
/api/recipes/all?sort=name&limit=50&offset=Nseitenweise bis weniger als 50 Treffer kommen (Endpoint cappt aktuell auf 50 pro Request, siehe/api/recipes/all/+server.ts). - Alle IDs in Cache-Manifest-Entry schreiben (
kochwas-metacache, key/cache-manifest). - Für jede ID: parallel (max. 4 gleichzeitig) cachen:
GET /recipes/{id}→data-BucketGET /api/recipes/{id}→data-Bucket- Aus der JSON-Response
image_pathextrahieren, wenn vorhandenGET /images/{image_path}→images-Bucket
- Nach jedem erfolgreichen Eintrag:
postMessage({ type: 'sync-progress', current, total })an alle Clients. - Am Ende:
postMessage({ type: 'sync-done', lastSynced: Date.now() }).
Update (bei jedem App-Start online):
- Client postet
{ type: 'sync-check' }an SW. - SW fetcht
/api/recipes/allfrisch. - Diff gegen Cache-Manifest:
- Neue IDs → cachen wie oben (nur Delta).
- Gelöschte IDs → aus
data- undimages-Bucket räumen.
- Wenn Delta leer →
sync-donemit 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:
type SyncState =
| { kind: 'idle' }
| { kind: 'syncing'; current: number; total: number }
| { kind: 'error'; message: string };
export const syncStatus = {
state: $state<SyncState>({ kind: 'idle' }),
lastSynced: $state<number | null>(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:
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
<SyncIndicator /> — 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)
<Toast /> — in +layout.svelte am Top eingehängt. Kurze, nicht-blockierende Meldungen. Store-API:
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:
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:
{
"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-Storesrc/lib/client/network.svelte.ts— Online-Status-Storesrc/lib/client/toast.svelte.ts— Toast-Storesrc/lib/components/SyncIndicator.svelte— bottom-right Pill + Overlay-Kartesrc/lib/components/Toast.svelte— Toast-Renderersrc/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-Logiktests/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,<SyncIndicator />+<Toast />einbinden, Network-Store initialisierensrc/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 Messagestoast.svelte.ts: Store-API, Auto-Dismisssw-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-checkfeuert, 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 |