Files
kochwas/docs/superpowers/plans/2026-04-18-offline-pwa.md
hsiegeln 60f6db9091
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
docs(plan): v1.1 Offline-PWA Implementierungsplan
15 bite-sized Tasks mit exakten Pfaden und Code-Blöcken. Reihenfolge:
Icons/Manifest → Stores (Network/Toast/Sync-Status) → SyncIndicator →
Cache-Strategy/Diff pure Funktionen → SW-Gerüst → Pre-Cache-Orchestrator
→ Schreib-Aktionen mit Offline-Check → Admin-App-Tab → Playwright →
E2E-Tests → Docs-Updates → Final Manual Checks.

TDD wo sinnvoll (Pure Functions + Stores). SW selber manuell getestet
via preview + DevTools, plus Playwright-E2E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:07:11 +02:00

1983 lines
61 KiB
Markdown

# Offline-PWA v1.1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Kochwas als installierbare PWA mit vollständigem Lese-Offline-Modus — alle Rezepte + Bilder lokal gecacht, dezenter Sync-Indikator, keine Backend-Änderungen.
**Architecture:** SvelteKits eingebauter Service Worker (`src/service-worker.ts`) mit drei Cache-Buckets (Shell/Daten/Bilder), Hintergrund-Pre-Cache und SWR-Updates. Frontend-Stores (`$state`-Klassen im Projekt-Muster aus `profile.svelte.ts`) für Network-Status, Sync-Status, Toasts. Neuer Admin-Tab „App".
**Tech Stack:** SvelteKit 2, Svelte 5 Runes, TypeScript, `$service-worker`-Modul, Cache API, `navigator.storage`, Playwright (neu), vitest (bestehend), `sharp` (neue devDep für Icon-Rendering).
Spec referenziert: `docs/superpowers/specs/2026-04-18-offline-pwa-design.md`.
---
## File Structure (Ziel-Zustand)
**Neu:**
- `src/service-worker.ts` — SW Einstiegspunkt (install/activate/fetch/message). Orchestriert die Sub-Module.
- `src/lib/sw/cache-strategy.ts` — Reine Funktion `resolveStrategy(url)``'shell' | 'swr' | 'images' | 'network-only'`. Testbar ohne SW-Runtime.
- `src/lib/sw/diff-manifest.ts` — Reine Funktion `diffManifest(current, cached)``{ toAdd, toRemove }`. Testbar.
- `src/lib/client/network.svelte.ts``online`-Store basierend auf `navigator.onLine`.
- `src/lib/client/sync-status.svelte.ts` — Sync-State + `lastSynced`, lauscht auf SW-Messages.
- `src/lib/client/toast.svelte.ts` — Toast-Queue-Store.
- `src/lib/client/sw-register.ts` — SW-Registrierung + Message-Bridge.
- `src/lib/components/Toast.svelte` — Toast-Renderer, im `+layout.svelte`.
- `src/lib/components/SyncIndicator.svelte` — Pill unten rechts + Overlay-Karte.
- `src/routes/admin/app/+page.svelte` — Admin-Tab „App": Install-Button, Sync-Status, Reset.
- `scripts/render-icons.mjs` — Einmal-Skript mit `sharp`, rendert aus `static/icon.svg` die PNGs.
- `static/icon-192.png`, `static/icon-512.png` — Maskable-fähige PWA-Icons.
- `playwright.config.ts` — E2E-Konfig.
- `tests/e2e/offline.spec.ts` — E2E-Tests für PWA-Flows.
- `tests/unit/cache-strategy.test.ts`, `tests/unit/diff-manifest.test.ts`, `tests/unit/toast-store.test.ts`, `tests/unit/sync-status-store.test.ts`, `tests/unit/network-store.test.ts` — Unit-Tests.
**Geändert:**
- `src/routes/+layout.svelte` — SW-Registrierung, `<Toast />` + `<SyncIndicator />`.
- `src/routes/admin/+layout.svelte` — vierter Tab „App".
- `src/routes/+page.svelte`, `src/routes/recipes/+page.svelte`, `src/routes/recipes/[id]/+page.svelte`, `src/routes/wishlist/+page.svelte`, `src/routes/admin/**/+page.svelte` — proaktiver Offline-Check vor Schreib-Fetches.
- `static/manifest.webmanifest` — PNG-Icons + maskable.
- `package.json``sharp`, `@playwright/test` als devDep; neue Scripts `render:icons`, `test:e2e`.
---
## Task 1: Icon-Assets rendern + Manifest erweitern
**Files:**
- Create: `scripts/render-icons.mjs`
- Create: `static/icon-192.png`
- Create: `static/icon-512.png`
- Modify: `static/manifest.webmanifest`
- Modify: `package.json`
- [ ] **Step 1: sharp installieren**
Run: `npm install --save-dev sharp`
Expected: package.json updated, node_modules populated, no errors.
- [ ] **Step 2: Render-Skript anlegen**
Create `scripts/render-icons.mjs`:
```js
// Rendert PWA-Icons aus static/icon.svg in die Größen, die Android/iOS
// für Home-Screen-Icons bevorzugen. Einmal lokal ausführen und die
// PNGs committen — keine CI-Abhängigkeit.
import sharp from 'sharp';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..');
const src = await readFile(join(root, 'static/icon.svg'));
for (const size of [192, 512]) {
await sharp(src, { density: 400 })
.resize(size, size, { fit: 'contain', background: { r: 248, g: 250, b: 248, alpha: 1 } })
.png()
.toFile(join(root, `static/icon-${size}.png`));
console.log(`wrote static/icon-${size}.png`);
}
```
- [ ] **Step 3: Skript-Shortcut in package.json**
Add to `"scripts"` in `package.json`:
```json
"render:icons": "node scripts/render-icons.mjs"
```
- [ ] **Step 4: Icons rendern**
Run: `npm run render:icons`
Expected: Konsole zeigt `wrote static/icon-192.png` und `wrote static/icon-512.png`. Beide Dateien existieren.
- [ ] **Step 5: Manifest erweitern**
Replace entire contents of `static/manifest.webmanifest` with:
```json
{
"name": "Kochwas",
"short_name": "Kochwas",
"description": "Persönliches Rezeptbuch — lokal, einheitlich, küchentauglich",
"start_url": "/",
"display": "standalone",
"background_color": "#f8faf8",
"theme_color": "#2b6a3d",
"lang": "de",
"orientation": "portrait",
"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"
}
]
}
```
- [ ] **Step 6: Commit**
```bash
git add package.json package-lock.json scripts/render-icons.mjs static/icon-192.png static/icon-512.png static/manifest.webmanifest
git commit -m "feat(pwa): PNG-Icons 192/512 + Manifest maskable-fähig"
```
---
## Task 2: Network-Status-Store + Test
**Files:**
- Create: `src/lib/client/network.svelte.ts`
- Create: `tests/unit/network-store.test.ts`
- [ ] **Step 1: Test schreiben**
Create `tests/unit/network-store.test.ts`:
```ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('network store', () => {
let originalOnLine: PropertyDescriptor | undefined;
beforeEach(() => {
originalOnLine = Object.getOwnPropertyDescriptor(globalThis.navigator, 'onLine');
Object.defineProperty(globalThis.navigator, 'onLine', { value: true, configurable: true });
});
afterEach(() => {
if (originalOnLine) Object.defineProperty(globalThis.navigator, 'onLine', originalOnLine);
vi.restoreAllMocks();
});
it('reflects initial navigator.onLine', async () => {
const { network } = await import('../../src/lib/client/network.svelte');
expect(network.online).toBe(true);
});
it('updates on offline/online events', async () => {
const { network } = await import('../../src/lib/client/network.svelte');
Object.defineProperty(globalThis.navigator, 'onLine', { value: false, configurable: true });
window.dispatchEvent(new Event('offline'));
expect(network.online).toBe(false);
Object.defineProperty(globalThis.navigator, 'onLine', { value: true, configurable: true });
window.dispatchEvent(new Event('online'));
expect(network.online).toBe(true);
});
});
```
Note: These tests need a DOM. Add `environment: 'jsdom'` to the `vitest` config OR `// @vitest-environment jsdom` comment at top of the file. Use the file-level pragma so existing node tests aren't affected.
Prepend to the test file (top line):
```ts
// @vitest-environment jsdom
```
- [ ] **Step 2: jsdom installieren**
Run: `npm install --save-dev jsdom`
Expected: package-lock updates, no errors.
- [ ] **Step 3: Test ausführen — erwartet Failure (Modul fehlt)**
Run: `npx vitest run tests/unit/network-store.test.ts`
Expected: `Failed to load ... network.svelte` oder ähnlich.
- [ ] **Step 4: Store implementieren**
Create `src/lib/client/network.svelte.ts`:
```ts
// Reaktiver Online-Status, basierend auf navigator.onLine + events.
// Bewusst kein aktives Heuristik-Probing (Test-Fetches) — für unsere
// Zwecke reicht der Browser-Status.
class NetworkStore {
online = $state(typeof navigator === 'undefined' ? true : navigator.onLine);
init(): void {
if (typeof window === 'undefined') return;
window.addEventListener('online', () => (this.online = true));
window.addEventListener('offline', () => (this.online = false));
}
}
export const network = new NetworkStore();
```
- [ ] **Step 5: Test-Anpassung: `init()` aufrufen**
The test imports the module (which constructs the singleton), but events are only listened to after `init()`. Adjust test to call `network.init()` after import:
Edit `tests/unit/network-store.test.ts` — in `beforeEach`, add:
```ts
const { network } = await import('../../src/lib/client/network.svelte');
network.init();
```
Actually restructure so import + init happen per test. Replace test file with:
```ts
// @vitest-environment jsdom
import { describe, it, expect, beforeEach } from 'vitest';
describe('network store', () => {
beforeEach(() => {
// Reset module state for each test
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
});
it('reflects initial navigator.onLine and reacts to events', async () => {
const { network } = await import('../../src/lib/client/network.svelte');
network.init();
expect(network.online).toBe(true);
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
window.dispatchEvent(new Event('offline'));
expect(network.online).toBe(false);
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
window.dispatchEvent(new Event('online'));
expect(network.online).toBe(true);
});
});
```
- [ ] **Step 6: Tests laufen lassen**
Run: `npx vitest run tests/unit/network-store.test.ts`
Expected: 1 passed.
- [ ] **Step 7: Commit**
```bash
git add src/lib/client/network.svelte.ts tests/unit/network-store.test.ts package.json package-lock.json
git commit -m "feat(pwa): Online-Status-Store"
```
---
## Task 3: Toast-Store + Komponente + Tests
**Files:**
- Create: `src/lib/client/toast.svelte.ts`
- Create: `src/lib/components/Toast.svelte`
- Create: `tests/unit/toast-store.test.ts`
- Modify: `src/routes/+layout.svelte`
- [ ] **Step 1: Test schreiben**
Create `tests/unit/toast-store.test.ts`:
```ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('toast store', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('queues toasts with auto-dismiss', async () => {
const { toastStore } = await import('../../src/lib/client/toast.svelte');
toastStore.info('Hello');
expect(toastStore.toasts.length).toBe(1);
expect(toastStore.toasts[0].message).toBe('Hello');
expect(toastStore.toasts[0].kind).toBe('info');
vi.advanceTimersByTime(3000);
expect(toastStore.toasts.length).toBe(0);
});
it('supports error kind and manual dismiss', async () => {
const { toastStore } = await import('../../src/lib/client/toast.svelte');
const id = toastStore.error('Boom');
expect(toastStore.toasts[0].kind).toBe('error');
toastStore.dismiss(id);
expect(toastStore.toasts.length).toBe(0);
});
it('allows multiple concurrent toasts', async () => {
const { toastStore } = await import('../../src/lib/client/toast.svelte');
toastStore.info('A');
toastStore.info('B');
expect(toastStore.toasts.length).toBe(2);
});
});
```
- [ ] **Step 2: Test laufen — erwartet Failure**
Run: `npx vitest run tests/unit/toast-store.test.ts`
Expected: Module load failure.
- [ ] **Step 3: Store implementieren**
Create `src/lib/client/toast.svelte.ts`:
```ts
export type ToastKind = 'info' | 'error' | 'success';
export type Toast = { id: number; kind: ToastKind; message: string };
class ToastStore {
toasts = $state<Toast[]>([]);
private nextId = 1;
private readonly dismissMs = 3000;
private push(kind: ToastKind, message: string): number {
const id = this.nextId++;
this.toasts = [...this.toasts, { id, kind, message }];
setTimeout(() => this.dismiss(id), this.dismissMs);
return id;
}
info(message: string): number { return this.push('info', message); }
error(message: string): number { return this.push('error', message); }
success(message: string): number { return this.push('success', message); }
dismiss(id: number): void {
this.toasts = this.toasts.filter((t) => t.id !== id);
}
}
export const toastStore = new ToastStore();
```
- [ ] **Step 4: Tests laufen**
Run: `npx vitest run tests/unit/toast-store.test.ts`
Expected: 3 passed.
Note: The toast store mutates state via `$state` runes. Each test re-imports via `await import(...)`, but vitest module cache may reuse the singleton. Reset the store's internal list explicitly at the start of each test:
Adjust tests — in `beforeEach`, after `vi.useFakeTimers()`:
```ts
const mod = await import('../../src/lib/client/toast.svelte');
mod.toastStore.toasts = [];
```
Re-run tests, expect all pass.
- [ ] **Step 5: Toast-Komponente implementieren**
Create `src/lib/components/Toast.svelte`:
```svelte
<script lang="ts">
import { X } from 'lucide-svelte';
import { toastStore } from '$lib/client/toast.svelte';
</script>
<div class="toasts" aria-live="polite" aria-atomic="true">
{#each toastStore.toasts as t (t.id)}
<div class="toast" class:error={t.kind === 'error'} class:success={t.kind === 'success'}>
<span class="msg">{t.message}</span>
<button class="close" aria-label="Schließen" onclick={() => toastStore.dismiss(t.id)}>
<X size={14} strokeWidth={2} />
</button>
</div>
{/each}
</div>
<style>
.toasts {
position: fixed;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: flex;
flex-direction: column;
gap: 0.35rem;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
background: #2b6a3d;
color: white;
border-radius: 10px;
font-size: 0.9rem;
pointer-events: auto;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
max-width: min(92vw, 480px);
}
.toast.error { background: #c53030; }
.toast.success { background: #2b6a3d; }
.close {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 0.15rem;
opacity: 0.85;
}
.close:hover { opacity: 1; }
</style>
```
- [ ] **Step 6: Toast in Layout einbinden**
Edit `src/routes/+layout.svelte`: add at the top of `<script>`:
```ts
import Toast from '$lib/components/Toast.svelte';
```
And in the markup, place `<Toast />` as the first child of the root (outside any existing container — it's fixed-positioned anyway):
```svelte
<Toast />
```
(Einfügen unmittelbar nach Öffnen des Script-Blocks und vor dem bestehenden Hauptlayout.)
- [ ] **Step 7: Visueller Smoketest**
Run: `npm run dev`
Open `http://localhost:5173` → DevTools Console → `(await import('/src/lib/client/toast.svelte.ts')).toastStore.info('Hallo')`
Expected: Grüner Toast erscheint oben mittig, verschwindet nach 3 s.
(If Vite rejects the runtime import, trigger via a page that already imports the store — z.B. fügen wir im `+layout.svelte` temporär `onMount(() => toastStore.info('loaded'))` ein und entfernen's nach dem Test. Alternativ auf Task 6 warten, wo die Erstnutzung natürlicher kommt.)
- [ ] **Step 8: Commit**
```bash
git add src/lib/client/toast.svelte.ts src/lib/components/Toast.svelte tests/unit/toast-store.test.ts src/routes/+layout.svelte
git commit -m "feat(pwa): Toast-Store + Renderer"
```
---
## Task 4: Sync-Status-Store + Test
**Files:**
- Create: `src/lib/client/sync-status.svelte.ts`
- Create: `tests/unit/sync-status-store.test.ts`
- [ ] **Step 1: Test schreiben**
Create `tests/unit/sync-status-store.test.ts`:
```ts
import { describe, it, expect, beforeEach } from 'vitest';
describe('sync-status store', () => {
beforeEach(async () => {
const mod = await import('../../src/lib/client/sync-status.svelte');
mod.syncStatus.state = { kind: 'idle' };
mod.syncStatus.lastSynced = null;
});
it('processes progress messages', async () => {
const { syncStatus } = await import('../../src/lib/client/sync-status.svelte');
syncStatus.handle({ type: 'sync-progress', current: 3, total: 10 });
expect(syncStatus.state).toEqual({ kind: 'syncing', current: 3, total: 10 });
});
it('transitions to idle on sync-done and records timestamp', async () => {
const { syncStatus } = await import('../../src/lib/client/sync-status.svelte');
syncStatus.handle({ type: 'sync-start', total: 5 });
expect(syncStatus.state.kind).toBe('syncing');
syncStatus.handle({ type: 'sync-done', lastSynced: 1700000000000 });
expect(syncStatus.state).toEqual({ kind: 'idle' });
expect(syncStatus.lastSynced).toBe(1700000000000);
});
it('sets error state on sync-error', async () => {
const { syncStatus } = await import('../../src/lib/client/sync-status.svelte');
syncStatus.handle({ type: 'sync-error', message: 'Quota exceeded' });
expect(syncStatus.state).toEqual({ kind: 'error', message: 'Quota exceeded' });
});
});
```
- [ ] **Step 2: Failure überprüfen**
Run: `npx vitest run tests/unit/sync-status-store.test.ts`
Expected: Module load failure.
- [ ] **Step 3: Store implementieren**
Create `src/lib/client/sync-status.svelte.ts`:
```ts
// State, den der Service-Worker per postMessage befüllt. Die App
// spiegelt den Sync-Fortschritt im SyncIndicator.
export type SyncState =
| { kind: 'idle' }
| { kind: 'syncing'; current: number; total: number }
| { kind: 'error'; message: string };
export type SWMessage =
| { type: 'sync-start'; total: number }
| { type: 'sync-progress'; current: number; total: number }
| { type: 'sync-done'; lastSynced: number }
| { type: 'sync-error'; message: string };
const STORAGE_KEY = 'kochwas.sw.lastSynced';
function loadLastSynced(): number | null {
if (typeof localStorage === 'undefined') return null;
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function saveLastSynced(ts: number): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(STORAGE_KEY, String(ts));
}
class SyncStatusStore {
state = $state<SyncState>({ kind: 'idle' });
lastSynced = $state<number | null>(loadLastSynced());
handle(msg: SWMessage): void {
switch (msg.type) {
case 'sync-start':
this.state = { kind: 'syncing', current: 0, total: msg.total };
break;
case 'sync-progress':
this.state = { kind: 'syncing', current: msg.current, total: msg.total };
break;
case 'sync-done':
this.state = { kind: 'idle' };
this.lastSynced = msg.lastSynced;
saveLastSynced(msg.lastSynced);
break;
case 'sync-error':
this.state = { kind: 'error', message: msg.message };
break;
}
}
}
export const syncStatus = new SyncStatusStore();
```
- [ ] **Step 4: Tests laufen**
Run: `npx vitest run tests/unit/sync-status-store.test.ts`
Expected: 3 passed.
- [ ] **Step 5: Commit**
```bash
git add src/lib/client/sync-status.svelte.ts tests/unit/sync-status-store.test.ts
git commit -m "feat(pwa): Sync-Status-Store mit localStorage-Persistierung"
```
---
## Task 5: SyncIndicator-Komponente
**Files:**
- Create: `src/lib/components/SyncIndicator.svelte`
- Modify: `src/routes/+layout.svelte`
- [ ] **Step 1: Komponente implementieren**
Create `src/lib/components/SyncIndicator.svelte`:
```svelte
<script lang="ts">
import { RefreshCw, WifiOff } from 'lucide-svelte';
import { network } from '$lib/client/network.svelte';
import { syncStatus } from '$lib/client/sync-status.svelte';
let expanded = $state(false);
const label = $derived.by(() => {
if (syncStatus.state.kind === 'syncing') {
return `Sync ${syncStatus.state.current}/${syncStatus.state.total}`;
}
if (!network.online) return 'Offline';
return null;
});
function formatRelative(ts: number | null): string {
if (ts === null) return 'noch nicht synchronisiert';
const diffMs = Date.now() - ts;
const min = Math.round(diffMs / 60_000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min`;
const h = Math.round(min / 60);
if (h < 24) return `vor ${h} Std`;
const d = Math.round(h / 24);
return `vor ${d} Tag${d === 1 ? '' : 'en'}`;
}
function requestRefresh() {
navigator.serviceWorker?.controller?.postMessage({ type: 'sync-check' });
}
</script>
{#if label}
<div class="wrap">
<button
type="button"
class="pill"
class:offline={!network.online}
class:syncing={syncStatus.state.kind === 'syncing'}
aria-label={label}
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>
{#if !network.online}
<WifiOff size={14} strokeWidth={2} />
{:else}
<RefreshCw size={14} strokeWidth={2} class="spin" />
{/if}
<span>{label}</span>
</button>
{#if expanded}
<div class="card" role="dialog">
<p class="when">Zuletzt synchronisiert: {formatRelative(syncStatus.lastSynced)}</p>
<button class="refresh" type="button" onclick={requestRefresh} disabled={!network.online}>
Jetzt aktualisieren
</button>
</div>
{/if}
</div>
{/if}
<style>
.wrap {
position: fixed;
right: 0.75rem;
bottom: 0.75rem;
z-index: 50;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.65rem;
background: white;
border: 1px solid #cfd9d1;
border-radius: 999px;
color: #555;
font-size: 0.78rem;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
font-family: inherit;
}
.pill.offline {
color: #666;
background: #f1f3f1;
}
.pill.syncing {
color: #2b6a3d;
border-color: #b7d6c2;
background: #eaf4ed;
}
.pill :global(.spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.card {
background: white;
border: 1px solid #e4eae7;
border-radius: 10px;
padding: 0.6rem 0.75rem;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
font-size: 0.82rem;
min-width: 220px;
}
.when {
margin: 0 0 0.4rem;
color: #555;
}
.refresh {
padding: 0.4rem 0.7rem;
background: #2b6a3d;
color: white;
border: 0;
border-radius: 8px;
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
}
.refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
```
- [ ] **Step 2: Indicator im Layout einbinden**
Edit `src/routes/+layout.svelte`: import und rendern. Das Script-Import ergänzen:
```ts
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
```
Im Template, neben `<Toast />`:
```svelte
<SyncIndicator />
```
Zusätzlich im Script den Network-Store initialisieren (einmalig client-seitig):
```ts
import { onMount } from 'svelte';
import { network } from '$lib/client/network.svelte';
onMount(() => {
network.init();
});
```
(Wenn bereits ein `onMount` existiert, nur `network.init()` ergänzen.)
- [ ] **Step 3: Visueller Smoketest**
Run: `npm run dev`
In DevTools-Konsole:
```js
const mod = await import('/src/lib/client/sync-status.svelte.ts');
mod.syncStatus.handle({ type: 'sync-start', total: 10 });
mod.syncStatus.handle({ type: 'sync-progress', current: 3, total: 10 });
```
Expected: Pill unten rechts zeigt „Sync 3/10" mit drehendem Icon.
Dann: `mod.syncStatus.handle({ type: 'sync-done', lastSynced: Date.now() })` → Pill verschwindet.
Offline-Test: DevTools → Network → Throttling „Offline" → Pill zeigt „Offline".
- [ ] **Step 4: Commit**
```bash
git add src/lib/components/SyncIndicator.svelte src/routes/+layout.svelte
git commit -m "feat(pwa): SyncIndicator-Pill + Overlay-Karte"
```
---
## Task 6: Cache-Strategy-Funktion + Tests
**Files:**
- Create: `src/lib/sw/cache-strategy.ts`
- Create: `tests/unit/cache-strategy.test.ts`
- [ ] **Step 1: Test schreiben**
Create `tests/unit/cache-strategy.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { resolveStrategy } from '../../src/lib/sw/cache-strategy';
describe('resolveStrategy', () => {
it('images bucket for /images/*', () => {
expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images');
});
it('swr for recipe HTML pages', () => {
expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('swr');
});
it('swr for recipe API reads', () => {
expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('swr');
expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe('swr');
expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('swr');
});
it('network-only for write methods', () => {
expect(resolveStrategy({ url: '/api/recipes/42', method: 'PATCH' })).toBe('network-only');
expect(resolveStrategy({ url: '/api/recipes/42/favorite', method: 'PUT' })).toBe('network-only');
expect(resolveStrategy({ url: '/api/wishlist', method: 'POST' })).toBe('network-only');
});
it('network-only for online-only endpoints even on GET', () => {
expect(resolveStrategy({ url: '/api/recipes/import', method: 'GET' })).toBe('network-only');
expect(resolveStrategy({ url: '/api/recipes/preview?url=x', method: 'GET' })).toBe('network-only');
expect(resolveStrategy({ url: '/api/recipes/search/web?q=x', method: 'GET' })).toBe('network-only');
});
it('shell bucket for build/static assets', () => {
expect(resolveStrategy({ url: '/_app/immutable/chunks/x.js', method: 'GET' })).toBe('shell');
expect(resolveStrategy({ url: '/icon-192.png', method: 'GET' })).toBe('shell');
expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell');
});
it('falls through to swr for other same-origin GETs (e.g. root page)', () => {
expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('swr');
expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('swr');
});
});
```
- [ ] **Step 2: Failure überprüfen**
Run: `npx vitest run tests/unit/cache-strategy.test.ts`
Expected: Module not found.
- [ ] **Step 3: Implementieren**
Create `src/lib/sw/cache-strategy.ts`:
```ts
export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only';
export type RequestShape = { url: string; method: string };
// Reine Funktion — einziger Entscheider für "welche Strategy für diesen
// Request?". Wird vom Service-Worker für jeden Fetch aufgerufen.
export function resolveStrategy(req: RequestShape): CacheStrategy {
// Alle Schreib-Methoden: niemals cachen.
if (req.method !== 'GET' && req.method !== 'HEAD') return 'network-only';
// URL auf den Pfad reduzieren — Query-String wird für's Matching
// nicht gebraucht, außer bei den online-only-Endpoints.
const path = req.url.startsWith('http') ? new URL(req.url).pathname : req.url.split('?')[0];
// Explizit online-only GETs
if (
path === '/api/recipes/import' ||
path === '/api/recipes/preview' ||
path.startsWith('/api/recipes/search/web')
) {
return 'network-only';
}
// Bilder
if (path.startsWith('/images/')) return 'images';
// App-Shell: Build-Assets und bekannte statische Dateien
if (
path.startsWith('/_app/') ||
path === '/manifest.webmanifest' ||
path === '/icon.svg' ||
path === '/icon-192.png' ||
path === '/icon-512.png' ||
path === '/favicon.ico' ||
path === '/robots.txt'
) {
return 'shell';
}
// Rest: Rezept-Seiten, API-Reads, Listen — alles SWR.
return 'swr';
}
```
- [ ] **Step 4: Tests laufen**
Run: `npx vitest run tests/unit/cache-strategy.test.ts`
Expected: All pass.
- [ ] **Step 5: Commit**
```bash
git add src/lib/sw/cache-strategy.ts tests/unit/cache-strategy.test.ts
git commit -m "feat(pwa): Cache-Strategy-Entscheider + Tests"
```
---
## Task 7: Diff-Manifest-Funktion + Tests
**Files:**
- Create: `src/lib/sw/diff-manifest.ts`
- Create: `tests/unit/diff-manifest.test.ts`
- [ ] **Step 1: Test schreiben**
Create `tests/unit/diff-manifest.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { diffManifest } from '../../src/lib/sw/diff-manifest';
describe('diffManifest', () => {
it('detects new IDs to add', () => {
const result = diffManifest([1, 2, 3, 4], [1, 2]);
expect(result.toAdd.sort()).toEqual([3, 4]);
expect(result.toRemove).toEqual([]);
});
it('detects removed IDs', () => {
const result = diffManifest([1, 2], [1, 2, 3, 4]);
expect(result.toAdd).toEqual([]);
expect(result.toRemove.sort()).toEqual([3, 4]);
});
it('detects both add and remove in one diff', () => {
const result = diffManifest([1, 3, 5], [1, 2, 3]);
expect(result.toAdd).toEqual([5]);
expect(result.toRemove).toEqual([2]);
});
it('returns empty arrays when identical', () => {
const result = diffManifest([1, 2, 3], [3, 2, 1]);
expect(result.toAdd).toEqual([]);
expect(result.toRemove).toEqual([]);
});
it('handles empty caches (first sync)', () => {
const result = diffManifest([1, 2], []);
expect(result.toAdd.sort()).toEqual([1, 2]);
expect(result.toRemove).toEqual([]);
});
});
```
- [ ] **Step 2: Failure überprüfen**
Run: `npx vitest run tests/unit/diff-manifest.test.ts`
Expected: Module not found.
- [ ] **Step 3: Implementieren**
Create `src/lib/sw/diff-manifest.ts`:
```ts
// Vergleicht die aktuelle Rezept-ID-Liste (vom Server) mit dem, was
// der Cache schon hat. Der SW nutzt das Delta, um nur Neue zu laden
// und Gelöschte abzuräumen.
export type ManifestDiff = { toAdd: number[]; toRemove: number[] };
export function diffManifest(currentIds: number[], cachedIds: number[]): ManifestDiff {
const current = new Set(currentIds);
const cached = new Set(cachedIds);
const toAdd: number[] = [];
const toRemove: number[] = [];
for (const id of current) if (!cached.has(id)) toAdd.push(id);
for (const id of cached) if (!current.has(id)) toRemove.push(id);
return { toAdd, toRemove };
}
```
- [ ] **Step 4: Tests laufen**
Run: `npx vitest run tests/unit/diff-manifest.test.ts`
Expected: 5 passed.
- [ ] **Step 5: Commit**
```bash
git add src/lib/sw/diff-manifest.ts tests/unit/diff-manifest.test.ts
git commit -m "feat(pwa): Cache-Manifest-Diff + Tests"
```
---
## Task 8: Service-Worker Gerüst (install + activate + fetch)
**Files:**
- Create: `src/service-worker.ts`
- Create: `src/lib/client/sw-register.ts`
- Modify: `src/routes/+layout.svelte`
- [ ] **Step 1: SW-Datei anlegen (Shell-Cache + Fetch-Dispatch)**
Create `src/service-worker.ts`:
```ts
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
import { resolveStrategy } from '$lib/sw/cache-strategy';
declare const self: ServiceWorkerGlobalScope;
const SHELL_CACHE = `kochwas-shell-${version}`;
const DATA_CACHE = 'kochwas-data-v1';
const IMAGES_CACHE = 'kochwas-images-v1';
// App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt)
const SHELL_ASSETS = [...build, ...files];
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
await cache.addAll(SHELL_ASSETS);
await self.skipWaiting();
})()
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Alte Shell-Caches (vorherige Versionen) räumen
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k.startsWith('kochwas-shell-') && k !== SHELL_CACHE)
.map((k) => caches.delete(k))
);
await self.clients.claim();
})()
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (new URL(req.url).origin !== self.location.origin) return; // Cross-Origin unangetastet
const strategy = resolveStrategy({ url: req.url, method: req.method });
if (strategy === 'network-only') return;
if (strategy === 'shell') {
event.respondWith(cacheFirst(req, SHELL_CACHE));
} else if (strategy === 'images') {
event.respondWith(cacheFirst(req, IMAGES_CACHE));
} else if (strategy === 'swr') {
event.respondWith(staleWhileRevalidate(req, DATA_CACHE));
}
});
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
if (hit) return hit;
const fresh = await fetch(req);
if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {});
return fresh;
}
async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
const fetchPromise = fetch(req)
.then((res) => {
if (res.ok) cache.put(req, res.clone()).catch(() => {});
return res;
})
.catch(() => hit ?? Response.error());
return hit ?? fetchPromise;
}
export {};
```
- [ ] **Step 2: SW-Registrierung client-seitig**
Create `src/lib/client/sw-register.ts`:
```ts
// Registriert den Service-Worker und verdrahtet ihn mit dem
// Sync-Status-Store. Im Dev-Modus läuft Kochwas über HTTP; die
// SW-API ist da nur auf localhost verfügbar. SvelteKit liefert den
// SW unter /service-worker.js im Production-Build.
import { syncStatus, type SWMessage } from '$lib/client/sync-status.svelte';
export async function registerServiceWorker(): Promise<void> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
try {
await navigator.serviceWorker.register('/service-worker.js', { type: 'module' });
} catch (e) {
console.warn('SW-Registrierung fehlgeschlagen', e);
return;
}
navigator.serviceWorker.addEventListener('message', (event) => {
const data = event.data as SWMessage | undefined;
if (data && typeof data === 'object' && 'type' in data) {
syncStatus.handle(data);
}
});
}
```
- [ ] **Step 3: Im Layout registrieren**
Edit `src/routes/+layout.svelte` — im `<script>`:
```ts
import { registerServiceWorker } from '$lib/client/sw-register';
```
Im `onMount`-Block:
```ts
void registerServiceWorker();
```
(Neben `network.init()` aus Task 5.)
- [ ] **Step 4: Production-Build + Preview**
Run: `npm run build && npm run preview`
Open `http://localhost:4173`
DevTools → Application → Service Workers: sollte einen aktiven SW zeigen.
DevTools → Application → Cache Storage: `kochwas-shell-<version>` sollte gefüllt sein.
- [ ] **Step 5: Offline-Test (manuell)**
DevTools → Network → Throttling „Offline" → Reload.
Expected: Layout lädt trotzdem (App-Shell gecacht). Rezeptseiten funktionieren noch nicht (kein Pre-Cache), Sync-Indikator zeigt „Offline".
- [ ] **Step 6: Commit**
```bash
git add src/service-worker.ts src/lib/client/sw-register.ts src/routes/+layout.svelte
git commit -m "feat(pwa): Service-Worker mit Shell-Cache + Fetch-Dispatch"
```
---
## Task 9: SW — Pre-Cache-Orchestrator (sync-start)
**Files:**
- Modify: `src/service-worker.ts`
- [ ] **Step 1: Pre-Cache-Logik ergänzen**
Edit `src/service-worker.ts` — add below the existing fetch handler:
```ts
const META_CACHE = 'kochwas-meta';
const MANIFEST_KEY = '/__cache-manifest__';
const PAGE_SIZE = 50; // /api/recipes/all limitiert auf 50
const CONCURRENCY = 4;
type RecipeSummary = { id: number; image_path: string | null };
self.addEventListener('message', (event) => {
const data = event.data as { type?: string } | undefined;
if (!data) return;
if (data.type === 'sync-start') {
event.waitUntil(runSync(false));
} else if (data.type === 'sync-check') {
event.waitUntil(runSync(true));
}
});
async function runSync(isUpdate: boolean): Promise<void> {
try {
const summaries = await fetchAllSummaries();
const currentIds = summaries.map((s) => s.id);
const cachedIds = await loadCachedIds();
const { toAdd, toRemove } = diffIds(currentIds, cachedIds);
const worklist = isUpdate ? toAdd : currentIds; // initial: alles laden
await broadcast({ type: 'sync-start', total: worklist.length });
let done = 0;
const tasks = worklist.map((id) => async () => {
const summary = summaries.find((s) => s.id === id);
await cacheRecipe(id, summary?.image_path ?? null);
done += 1;
await broadcast({ type: 'sync-progress', current: done, total: worklist.length });
});
await runPool(tasks, CONCURRENCY);
if (isUpdate && toRemove.length > 0) {
await removeRecipes(toRemove);
}
await saveCachedIds(currentIds);
await broadcast({ type: 'sync-done', lastSynced: Date.now() });
} catch (e) {
await broadcast({
type: 'sync-error',
message: (e as Error).message ?? 'Unbekannter Sync-Fehler'
});
}
}
async function fetchAllSummaries(): Promise<RecipeSummary[]> {
const result: RecipeSummary[] = [];
let offset = 0;
for (;;) {
const res = await fetch(`/api/recipes/all?sort=name&limit=${PAGE_SIZE}&offset=${offset}`);
if (!res.ok) throw new Error(`/api/recipes/all HTTP ${res.status}`);
const body = (await res.json()) as { hits: { id: number; image_path: string | null }[] };
result.push(...body.hits.map((h) => ({ id: h.id, image_path: h.image_path })));
if (body.hits.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
return result;
}
async function cacheRecipe(id: number, imagePath: string | null): Promise<void> {
const data = await caches.open(DATA_CACHE);
const images = await caches.open(IMAGES_CACHE);
await Promise.all([
addToCache(data, `/recipes/${id}`),
addToCache(data, `/api/recipes/${id}`),
imagePath && !/^https?:\/\//i.test(imagePath)
? addToCache(images, `/images/${imagePath}`)
: Promise.resolve()
]);
}
async function addToCache(cache: Cache, url: string): Promise<void> {
try {
const res = await fetch(url);
if (res.ok) await cache.put(url, res);
} catch {
// Einzelne Fehler ignorieren — nächster Sync holt's nach.
}
}
async function removeRecipes(ids: number[]): Promise<void> {
const data = await caches.open(DATA_CACHE);
for (const id of ids) {
await data.delete(`/recipes/${id}`);
await data.delete(`/api/recipes/${id}`);
}
// Orphan-Bilder: wir räumen nicht aktiv — neuer Hash = neuer Entry,
// alte Einträge stören nicht. Quota-Check im Layout würde eingreifen.
}
async function loadCachedIds(): Promise<number[]> {
const meta = await caches.open(META_CACHE);
const res = await meta.match(MANIFEST_KEY);
if (!res) return [];
try {
return (await res.json()) as number[];
} catch {
return [];
}
}
async function saveCachedIds(ids: number[]): Promise<void> {
const meta = await caches.open(META_CACHE);
await meta.put(
MANIFEST_KEY,
new Response(JSON.stringify(ids), { headers: { 'content-type': 'application/json' } })
);
}
function diffIds(current: number[], cached: number[]): { toAdd: number[]; toRemove: number[] } {
const cur = new Set(current);
const cac = new Set(cached);
return {
toAdd: [...cur].filter((id) => !cac.has(id)),
toRemove: [...cac].filter((id) => !cur.has(id))
};
}
async function runPool<T>(tasks: (() => Promise<T>)[], limit: number): Promise<void> {
const executing: Promise<void>[] = [];
for (const task of tasks) {
const p: Promise<void> = task().then(() => {
executing.splice(executing.indexOf(p), 1);
});
executing.push(p);
if (executing.length >= limit) await Promise.race(executing);
}
await Promise.all(executing);
}
async function broadcast(msg: unknown): Promise<void> {
const clients = await self.clients.matchAll();
for (const client of clients) client.postMessage(msg);
}
```
**Wichtig**: Der lokale `diffIds` ist ein Duplikat von Task 7 — **entfernen und stattdessen importieren**. Oben im File den Import ergänzen:
```ts
import { diffManifest } from '$lib/sw/diff-manifest';
```
Im `runSync` ersetze `diffIds(currentIds, cachedIds)` durch `diffManifest(currentIds, cachedIds)`, und lösche die lokale `diffIds`-Funktion am Ende der Datei vollständig.
**Storage-Quota-Check vor dem Pre-Cache** — direkt am Anfang von `runSync`, vor `fetchAllSummaries`:
```ts
if (navigator.storage?.estimate) {
const est = await navigator.storage.estimate();
const freeBytes = (est.quota ?? 0) - (est.usage ?? 0);
if (freeBytes < 100 * 1024 * 1024) {
await broadcast({
type: 'sync-error',
message: `Nicht genug Speicher für Offline-Modus (${Math.round(freeBytes / 1024 / 1024)} MB frei)`
});
return;
}
}
```
- [ ] **Step 2: Client triggert Sync beim App-Start**
Edit `src/lib/client/sw-register.ts` — am Ende von `registerServiceWorker`, nach dem `addEventListener('message', ...)`:
```ts
// Beim App-Start: wenn wir einen aktiven SW haben, frage ihn, ob er
// neu synct (initial oder Delta).
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'sync-check' });
} else {
// Erste Session: SW kommt erst mit dem nächsten Reload zum Einsatz.
// Beim nächsten Start triggert sync-check dann den Initial-Sync.
navigator.serviceWorker.ready.then((reg) => {
reg.active?.postMessage({ type: 'sync-start' });
});
}
```
- [ ] **Step 3: Production-Build + Preview**
Run: `npm run build && npm run preview`
Open `http://localhost:4173` — warte einen Moment, SW sollte rotieren, Sync-Indikator zeigt „Sync N/M" und steigt bis zum Ende.
- [ ] **Step 4: DevTools-Check**
Application → Cache Storage:
- `kochwas-shell-<version>`: Build-Assets
- `kochwas-data-v1`: enthält `/recipes/1`, `/api/recipes/1`, etc.
- `kochwas-images-v1`: enthält alle `/images/*`
- `kochwas-meta`: enthält `/__cache-manifest__` mit JSON-Array der IDs
- [ ] **Step 5: Offline-Rezept-Test**
Network → Offline → Navigiere im App zu `/recipes/1` oder irgendeiner bekannten Rezept-URL.
Expected: Seite lädt komplett (Zutaten, Schritte, Bild).
- [ ] **Step 6: Commit**
```bash
git add src/service-worker.ts src/lib/client/sw-register.ts
git commit -m "feat(pwa): SW Pre-Cache-Orchestrator mit Fortschritt + Delta-Sync"
```
---
## Task 10: Schreib-Aktionen — Offline-Check + Toast
**Files:**
- Modify: `src/routes/recipes/[id]/+page.svelte`
- Modify: `src/routes/recipes/+page.svelte`
- Modify: `src/routes/wishlist/+page.svelte`
- Modify: `src/routes/admin/domains/+page.svelte`
- Modify: `src/routes/admin/profiles/+page.svelte`
- Modify: `src/routes/admin/backup/+page.svelte`
- Modify: `src/routes/+page.svelte`
- [ ] **Step 1: Helfer anlegen**
Create `src/lib/client/require-online.ts`:
```ts
import { network } from './network.svelte';
import { toastStore } from './toast.svelte';
// Soll vor jedem Schreib-Fetch aufgerufen werden. Liefert true wenn
// online (User darf weitermachen) oder false + Toast wenn offline.
export function requireOnline(action = 'Die Aktion'): boolean {
if (network.online) return true;
toastStore.error(`${action} braucht eine Internet-Verbindung.`);
return false;
}
```
- [ ] **Step 2: In `/recipes/[id]/+page.svelte` einsetzen**
Edit `src/routes/recipes/[id]/+page.svelte`:
Import:
```ts
import { requireOnline } from '$lib/client/require-online';
```
Am Anfang jeder Schreib-Handler-Funktion (`setRating`, `toggleFavorite`, `toggleWishlist`, `logCooked`, `addComment`, `deleteRecipe`, `saveTitle`, `saveRecipe`), gleich nach dem optionalen Profil-Check und vor dem `fetch`:
```ts
if (!requireOnline('Diese Aktion')) return;
```
Passe die Labels pro Stelle an, z.B. `requireOnline('Das Rating')`, `requireOnline('Das Favorit-Setzen')` etc.
- [ ] **Step 3: In `/recipes/+page.svelte` (Register)**
Edit `src/routes/recipes/+page.svelte`:
- Import `requireOnline`
- In `submitImport` vor `goto`: `if (!requireOnline('Der URL-Import')) return;`
- In `createBlank` vor `fetch`: `if (!requireOnline('Das Anlegen eines Rezepts')) return;`
- [ ] **Step 4: In `/wishlist/+page.svelte`**
Edit `src/routes/wishlist/+page.svelte`:
- Import `requireOnline`
- In `toggleMine` nach dem Profil-Check, vor `fetch`: `if (!requireOnline('Die Wunschlisten-Aktion')) return;`
- In `removeForAll` vor `fetch`: `if (!requireOnline('Das Entfernen')) return;`
- [ ] **Step 5: In `/admin/**/+page.svelte`**
Edit `src/routes/admin/domains/+page.svelte`, `profiles/+page.svelte`, `backup/+page.svelte`:
- Jeweils `requireOnline` importieren.
- Jeder Schreib-Handler (add/remove/saveEdit, Backup-Export): `if (!requireOnline('…')) return;`.
- [ ] **Step 6: In `/+page.svelte` (Home)**
`dismissFromRecent` löst einen PATCH aus — `if (!requireOnline('Das Entfernen')) return;` am Anfang.
- [ ] **Step 7: Smoketest**
`npm run build && npm run preview``http://localhost:4173`
DevTools → Network → Offline → Irgendeinen Favorit-Button klicken.
Expected: Roter Toast „Das Favorit-Setzen braucht eine Internet-Verbindung." oben. Herz wird NICHT gefüllt.
- [ ] **Step 8: Tests laufen (keine Regressionen)**
```bash
npm run check
npm test
```
Expected: 0 Type-Errors, alle vitest-Tests grün.
- [ ] **Step 9: Commit**
```bash
git add src/lib/client/require-online.ts src/routes
git commit -m "feat(pwa): Schreib-Aktionen zeigen Offline-Toast statt stillem Fail"
```
---
## Task 11: Admin-Tab „App" mit Install-Button + Sync-Controls
**Files:**
- Create: `src/routes/admin/app/+page.svelte`
- Modify: `src/routes/admin/+layout.svelte`
- Create: `src/lib/client/install-prompt.svelte.ts`
- [ ] **Step 1: Install-Prompt-Store**
Create `src/lib/client/install-prompt.svelte.ts`:
```ts
// Fängt das beforeinstallprompt-Event (Android Chrome), hält es zur
// manuellen Triggerung durch den User vor. Auf iOS Safari existiert
// das Event nicht — wir erkennen den Browser per UserAgent und zeigen
// eine Info ("Teilen → Zum Home-Bildschirm").
class InstallPromptStore {
available = $state(false);
platform = $state<'android' | 'ios' | 'other'>('other');
private deferred: BeforeInstallPromptEvent | null = null;
init(): void {
if (typeof window === 'undefined') return;
this.platform = detectPlatform();
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this.deferred = e as BeforeInstallPromptEvent;
this.available = true;
});
window.addEventListener('appinstalled', () => {
this.deferred = null;
this.available = false;
});
}
async prompt(): Promise<void> {
if (!this.deferred) return;
await this.deferred.prompt();
this.deferred = null;
this.available = false;
}
}
function detectPlatform(): 'android' | 'ios' | 'other' {
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
if (/Android/i.test(ua)) return 'android';
return 'other';
}
// Minimal-Typ für das Chrome-eigene Event
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
export const installPrompt = new InstallPromptStore();
```
- [ ] **Step 2: Im Layout initialisieren**
Edit `src/routes/+layout.svelte` — in `onMount`:
```ts
import { installPrompt } from '$lib/client/install-prompt.svelte';
// ...
installPrompt.init();
```
- [ ] **Step 3: Admin-Layout — vierter Tab**
Edit `src/routes/admin/+layout.svelte`:
Import:
```ts
import { Smartphone } from 'lucide-svelte';
```
Erweitere das `items`-Array um einen vierten Eintrag:
```ts
{ href: '/admin/app', label: 'App', icon: Smartphone }
```
- [ ] **Step 4: App-Tab-Page**
Create `src/routes/admin/app/+page.svelte`:
```svelte
<script lang="ts">
import { Download, RefreshCw, Trash2 } from 'lucide-svelte';
import { installPrompt } from '$lib/client/install-prompt.svelte';
import { syncStatus } from '$lib/client/sync-status.svelte';
import { network } from '$lib/client/network.svelte';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
import { toastStore } from '$lib/client/toast.svelte';
import { requireOnline } from '$lib/client/require-online';
function triggerInstall() {
void installPrompt.prompt();
}
function triggerSync() {
if (!requireOnline('Das Synchronisieren')) return;
navigator.serviceWorker?.controller?.postMessage({ type: 'sync-check' });
}
async function clearCache() {
const ok = await confirmAction({
title: 'Offline-Cache leeren?',
message: 'Alle lokal gespeicherten Rezepte und Bilder werden entfernt. Beim nächsten Online-Start werden sie neu geladen.',
confirmLabel: 'Leeren',
destructive: true
});
if (!ok) return;
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k.startsWith('kochwas-')).map((k) => caches.delete(k)));
toastStore.success('Cache geleert. Lade jetzt neu.');
}
function formatTime(ts: number | null): string {
if (ts === null) return 'noch nicht';
return new Date(ts).toLocaleString('de-DE');
}
</script>
<h1>App</h1>
<p class="intro">Einstellungen für die Installation und den Offline-Cache.</p>
<section class="card">
<h2>Installieren</h2>
{#if installPrompt.platform === 'ios'}
<p>Öffne das Teilen-Menü in Safari und wähle <strong>„Zum Home-Bildschirm hinzufügen"</strong>.</p>
{:else if installPrompt.available}
<button type="button" class="btn primary" onclick={triggerInstall}>
<Download size={16} strokeWidth={2} /> Als App installieren
</button>
{:else}
<p class="muted">
Installation aktuell nicht möglich (entweder schon installiert oder Browser unterstützt es nicht).
</p>
{/if}
</section>
<section class="card">
<h2>Offline-Synchronisation</h2>
{#if syncStatus.state.kind === 'syncing'}
<p>Lädt gerade: {syncStatus.state.current}/{syncStatus.state.total} Rezepte.</p>
{:else if syncStatus.state.kind === 'error'}
<p class="error">Fehler: {syncStatus.state.message}</p>
{:else}
<p>Zuletzt synchronisiert: {formatTime(syncStatus.lastSynced)}</p>
{/if}
<button type="button" class="btn" onclick={triggerSync} disabled={!network.online}>
<RefreshCw size={16} strokeWidth={2} /> Jetzt synchronisieren
</button>
</section>
<section class="card">
<h2>Cache</h2>
<p class="muted">Nur bei Problemen: entfernt alle Offline-Daten.</p>
<button type="button" class="btn danger" onclick={clearCache}>
<Trash2 size={16} strokeWidth={2} /> Offline-Cache leeren
</button>
</section>
<style>
h1 { font-size: 1.3rem; margin: 0 0 0.5rem; }
.intro { color: #666; margin: 0 0 1rem; font-size: 0.95rem; }
.card {
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.card h2 { margin: 0 0 0.5rem; font-size: 1rem; color: #2b6a3d; }
.card p { margin: 0 0 0.6rem; font-size: 0.93rem; }
.muted { color: #888; }
.error { color: #c53030; }
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.9rem;
border: 1px solid #cfd9d1;
background: white;
border-radius: 10px;
cursor: pointer;
font-size: 0.92rem;
min-height: 40px;
font-family: inherit;
}
.btn.primary { background: #2b6a3d; color: white; border: 0; }
.btn.danger { color: #c53030; border-color: #f1b4b4; }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
</style>
```
- [ ] **Step 5: Check + Smoketest**
```bash
npm run check
npm run build && npm run preview
```
Open `http://localhost:4173/admin/app` — alle drei Karten sichtbar, „Installieren"-Button auf Android verfügbar (auf Desktop eventuell nicht, je nach Browser).
- [ ] **Step 6: Commit**
```bash
git add src/lib/client/install-prompt.svelte.ts src/routes/admin/app src/routes/admin/+layout.svelte src/routes/+layout.svelte
git commit -m "feat(pwa): Admin-Tab 'App' mit Install-Button + Sync-Controls"
```
---
## Task 12: Playwright installieren + konfigurieren
**Files:**
- Create: `playwright.config.ts`
- Create: `tests/e2e/.gitkeep` (oder erster Test)
- Modify: `package.json`
- [ ] **Step 1: Playwright installieren**
Run:
```bash
npm install --save-dev @playwright/test
npx playwright install chromium
```
Expected: Chromium-Browser downloaded, no error.
- [ ] **Step 2: Config-Datei**
Create `playwright.config.ts`:
```ts
import { defineConfig } from '@playwright/test';
// E2E-Tests nutzen den SvelteKit-Preview-Build. `npm run build` muss
// vor den Tests gelaufen sein — Playwright startet dann nur den
// Preview-Server (kein Dev-Server, damit der SW registrierbar ist).
export default defineConfig({
testDir: 'tests/e2e',
fullyParallel: false,
reporter: 'list',
use: {
baseURL: 'http://localhost:4173',
headless: true,
serviceWorkers: 'allow'
},
webServer: {
command: 'npm run preview',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
timeout: 30_000
}
});
```
- [ ] **Step 3: Scripts in package.json**
Add:
```json
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
```
- [ ] **Step 4: Smoke-Test schreiben**
Create `tests/e2e/smoke.spec.ts`:
```ts
import { test, expect } from '@playwright/test';
test('home loads', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Kochwas');
});
```
- [ ] **Step 5: Build + E2E laufen**
```bash
npm run build
npm run test:e2e
```
Expected: 1 passed.
- [ ] **Step 6: Commit**
```bash
git add playwright.config.ts tests/e2e/smoke.spec.ts package.json package-lock.json
git commit -m "chore(test): Playwright für E2E-Tests aufgesetzt"
```
---
## Task 13: E2E — Offline-Navigation
**Files:**
- Create: `tests/e2e/offline.spec.ts`
- [ ] **Step 1: Hilfs-Funktionen + erster Test**
Create `tests/e2e/offline.spec.ts`:
```ts
import { test, expect, type Page } from '@playwright/test';
// Wartet, bis der Service Worker aktiv ist und den initialen Sync
// durchgelaufen hat. Wir pollen den sync-status-Store im Fenster.
async function waitForSync(page: Page) {
await page.waitForFunction(
async () => {
const r = await navigator.serviceWorker.ready;
return !!r.active;
},
null,
{ timeout: 10_000 }
);
// Heuristik: warte bis keine Sync-Pill mehr sichtbar ist oder timeout
await page.waitForTimeout(3000);
}
test('offline navigation zeigt Rezept-Detail aus dem Cache', async ({ page, context }) => {
await page.goto('/');
await waitForSync(page);
// Eine echte Rezept-ID suchen — voraussetzt, dass in der Test-DB mind.
// ein Rezept existiert. Alternativ: testen, dass /recipes selbst lädt.
await page.goto('/recipes');
const firstLink = page.locator('a[href^="/recipes/"]').first();
const href = await firstLink.getAttribute('href');
expect(href).toBeTruthy();
await context.setOffline(true);
await page.goto(href!);
await expect(page.locator('h1')).toBeVisible();
});
test('Offline-Schreib-Aktion zeigt Toast', async ({ page, context }) => {
await page.goto('/');
await waitForSync(page);
await context.setOffline(true);
await page.goto('/recipes');
const firstLink = page.locator('a[href^="/recipes/"]').first();
const href = await firstLink.getAttribute('href');
await page.goto(href!);
await page.getByRole('button', { name: /Favorit/ }).first().click();
await expect(page.locator('.toast.error')).toContainText(/Internet-Verbindung/);
});
test('SyncIndicator zeigt Offline-Status', async ({ page, context }) => {
await page.goto('/');
await waitForSync(page);
await context.setOffline(true);
await page.reload();
await expect(page.locator('.wrap .pill.offline')).toContainText('Offline');
});
```
Caveats:
- Diese Tests setzen voraus, dass die Dev-/Preview-Datenbank mindestens **ein Rezept** enthält. Falls leer, schlägt der erste Test fehl — in dem Fall vor dem Test-Run ein Rezept importieren oder manuell anlegen. Alternative: im Test-Setup über `/api/recipes/blank` ein Rezept anlegen.
- `.toast.error` + `.wrap .pill.offline` sind Selektor-Hooks, die mit den bereits geschriebenen Komponenten zusammenpassen sollten. Falls Anpassungen nötig, CSS-Klassen beibehalten.
- [ ] **Step 2: Test-DB vorbereiten (erforderlich, nicht optional)**
Die Tests brauchen mindestens **ein Rezept** in der lokalen DB. Füge am Anfang von `tests/e2e/offline.spec.ts` einen `beforeAll`-Hook hinzu, der über die API ein leeres Rezept erzeugt, falls noch keines existiert:
```ts
import { test as base, expect, type Page } from '@playwright/test';
const test = base.extend<{ seeded: void }>({
seeded: [
async ({ request }, use) => {
const res = await request.get('/api/recipes/all?sort=name&limit=1&offset=0');
const body = await res.json();
if (body.hits.length === 0) {
await request.post('/api/recipes/blank');
}
await use();
},
{ scope: 'worker', auto: true }
]
});
```
Die Tests nutzen dann den `test`-Export aus dieser Datei (nicht aus `@playwright/test` direkt) — dadurch läuft `seeded` automatisch einmal pro Worker.
- [ ] **Step 3: E2E laufen**
```bash
npm run build
npm run test:e2e -- offline
```
Expected: 3 tests passed.
Falls Tests flaky sind wegen SW-Timing: `waitForSync` auf konkretes Signal umstellen (z.B. per `page.evaluate` den `syncStatus`-Store abfragen).
- [ ] **Step 4: Commit**
```bash
git add tests/e2e/offline.spec.ts
git commit -m "test(pwa): E2E für Offline-Navigation, -Schreib-Toast, -Indikator"
```
---
## Task 14: Dokumentation + CLAUDE.md-Update
**Files:**
- Modify: `CLAUDE.md`
- Modify: `docs/OPERATIONS.md`
- Modify: `docs/ARCHITECTURE.md`
- [ ] **Step 1: CLAUDE.md — neue Gotcha + Struktur**
Edit `CLAUDE.md` — in der Gotcha-Tabelle ergänzen:
```markdown
| **Service Worker nur ab HTTPS** | `npm run dev` liefert HTTP → SW registriert nicht. Für PWA-Tests `npm run build && npm run preview` (localhost) oder Prod-Docker. |
| **Icon-Rendering** | `npm run render:icons` rendert `icon-192.png` + `icon-512.png` aus `static/icon.svg`. Nur nach SVG-Änderung erneut ausführen + committen. |
```
In „Dateien, die man typischerweise anfasst" ergänzen:
- `src/service-worker.ts` — SW-Orchestrator
- `src/lib/sw/` — reine Logik (Strategy, Diff) für Unit-Tests
- `src/lib/client/*.svelte.ts` — Frontend-Stores (Network, Sync-Status, Toast, Install-Prompt)
- [ ] **Step 2: OPERATIONS.md — PWA-Abschnitt**
Edit `docs/OPERATIONS.md` — neuen Abschnitt am Ende:
```markdown
## PWA / Offline-Modus
Kochwas ist eine installierbare PWA. Erkennbar an:
- `static/manifest.webmanifest` (Manifest + Icons)
- `src/service-worker.ts` (Cache + Sync)
Caches im Browser (siehe DevTools → Application → Cache Storage):
- `kochwas-shell-<version>` — App-Shell (JS/CSS/Static-Icons)
- `kochwas-data-v1` — Rezept-HTMLs + API-JSON (SWR)
- `kochwas-images-v1` — Bilder (cache-first)
- `kochwas-meta` — Cache-Manifest (Liste der gecachten IDs)
Bei SW-Problemen Debug-Pfad: DevTools → Application → Service Workers → Unregister, dann Seite neu laden. Alternative: Admin-Tab „App" → „Offline-Cache leeren".
E2E-Tests (Playwright): `npm run test:e2e`. Requires previous `npm run build`.
```
- [ ] **Step 3: ARCHITECTURE.md — kurzer Hinweis**
Edit `docs/ARCHITECTURE.md` — wenn ein Frontend-Modul-Abschnitt existiert, ergänzen:
```markdown
### Service Worker
`src/service-worker.ts` ist SvelteKits eingebauter SW-Slot. Er nutzt `$service-worker` (build, files, version) für den App-Shell-Cache und implementiert eigene Logik für Pre-Cache (alle Rezepte + Bilder), Delta-Sync beim App-Start und die drei Cache-Strategien (Shell=cache-first, Daten=SWR, Bilder=cache-first).
Reine Logik-Einheiten (testbar): `src/lib/sw/cache-strategy.ts`, `src/lib/sw/diff-manifest.ts`.
Client-Stores: `src/lib/client/{network,sync-status,toast,install-prompt}.svelte.ts`.
```
(Falls ARCHITECTURE.md keinen solchen Abschnitt hat, einfach an passender Stelle einfügen.)
- [ ] **Step 4: Commit**
```bash
git add CLAUDE.md docs/OPERATIONS.md docs/ARCHITECTURE.md
git commit -m "docs(pwa): CLAUDE.md, OPERATIONS, ARCHITECTURE aktualisiert"
```
---
## Task 15: End-to-End Manual-Check + Push
- [ ] **Step 1: Komplett-Build**
```bash
npm run check
npm test
npm run build
npm run test:e2e
```
Expected: Alles grün — 0 Errors, alle Unit-Tests + E2E passen.
- [ ] **Step 2: Preview — PWA-Full-Check**
```bash
npm run preview
```
- Öffne `http://localhost:4173` in Chrome
- DevTools → Lighthouse → Kategorie „Progressive Web App" → Report: sollte installability OK sein
- Application → Service Workers: `activated and is running`
- Application → Cache Storage: alle vier Caches gefüllt
- Admin → App: Install-Button sichtbar (Chrome Desktop), Sync-Status zeigt Zeit + Anzahl
- [ ] **Step 3: Docker-Compose-Prod (reale Umgebung)**
```bash
docker compose -f docker-compose.prod.yml up --build
```
Öffne in einem echten Smartphone-Browser `https://<pi-hostname>.siegeln.net``kochwas.siegeln.net` wenn schon deployed. Alternativ: Port 443 aus dem Netz, oder lokal `docker compose up` + manifest via localhost testen.
- Mobile Chrome: beforeinstallprompt → Install-Button funktioniert → App auf Home-Screen → App öffnen → standalone-Modus ohne Browser-UI.
- Im Flugmodus: App öffnen, zu Rezepten navigieren, lesen.
- Online wieder: Sync läuft im Hintergrund, neue Rezepte kommen dazu.
- [ ] **Step 4: Final-Push**
```bash
git push
```
- [ ] **Step 5: Version-Bump & Release-Tag**
Wenn v1.1 als Release markiert werden soll:
```bash
git tag -a v1.1.0 -m "Offline-PWA: alle Rezepte + Bilder lokal, installierbar, SWR-Updates"
git push --tags
```