Files
kochwas/docs/superpowers/specs/2026-04-18-offline-pwa-design.md
hsiegeln 303939a6ff
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 46s
docs(spec): v1.1 Offline-PWA Design
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>
2026-04-18 15:59:39 +02:00

12 KiB
Raw Permalink Blame History

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:

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-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, <SyncIndicator /> + <Toast /> 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