All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
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>
1983 lines
61 KiB
Markdown
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
|
|
```
|