docs(spec): v1.1 Offline-PWA Design
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 46s
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:
235
docs/superpowers/specs/2026-04-18-offline-pwa-design.md
Normal file
235
docs/superpowers/specs/2026-04-18-offline-pwa-design.md
Normal 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 |
|
||||||
Reference in New Issue
Block a user