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

61 KiB

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.tsonline-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.jsonsharp, @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:

// 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:

"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:

{
  "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
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:

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):

// @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:

// 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:

const { network } = await import('../../src/lib/client/network.svelte');
network.init();

Actually restructure so import + init happen per test. Replace test file with:

// @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
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:

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:

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():

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:

<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>:

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):

<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
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:

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:

// 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
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:

<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:

import SyncIndicator from '$lib/components/SyncIndicator.svelte';

Im Template, neben <Toast />:

<SyncIndicator />

Zusätzlich im Script den Network-Store initialisieren (einmalig client-seitig):

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:

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
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:

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:

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
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:

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:

// 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
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:

/// <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:

// 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>:

import { registerServiceWorker } from '$lib/client/sw-register';

Im onMount-Block:

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
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:

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:

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:

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', ...):

// 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
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:

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:

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:

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 previewhttp://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)
npm run check
npm test

Expected: 0 Type-Errors, alle vitest-Tests grün.

  • Step 9: Commit
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:

// 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:

import { installPrompt } from '$lib/client/install-prompt.svelte';
// ...
installPrompt.init();
  • Step 3: Admin-Layout — vierter Tab

Edit src/routes/admin/+layout.svelte:

Import:

import { Smartphone } from 'lucide-svelte';

Erweitere das items-Array um einen vierten Eintrag:

{ href: '/admin/app', label: 'App', icon: Smartphone }
  • Step 4: App-Tab-Page

Create src/routes/admin/app/+page.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
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
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:

npm install --save-dev @playwright/test
npx playwright install chromium

Expected: Chromium-Browser downloaded, no error.

  • Step 2: Config-Datei

Create playwright.config.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:

"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
  • Step 4: Smoke-Test schreiben

Create tests/e2e/smoke.spec.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
npm run build
npm run test:e2e

Expected: 1 passed.

  • Step 6: Commit
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:

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:

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
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
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:

| **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:

## 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:

### 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
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
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
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)

docker compose -f docker-compose.prod.yml up --build

Öffne in einem echten Smartphone-Browser https://<pi-hostname>.siegeln.netkochwas.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

git push
  • Step 5: Version-Bump & Release-Tag

Wenn v1.1 als Release markiert werden soll:

git tag -a v1.1.0 -m "Offline-PWA: alle Rezepte + Bilder lokal, installierbar, SWR-Updates"
git push --tags