docs(spec): v1.1 Offline-PWA Design
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 46s

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>
This commit is contained in:
hsiegeln
2026-04-18 15:59:39 +02:00
parent 2807dd1cab
commit 303939a6ff

View File

@@ -0,0 +1,235 @@
# 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<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`:
```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:
```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, `<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 |