28 Commits

Author SHA1 Message Date
hsiegeln
854af2fc34 fix(pwa): Reload-Loop beim Zombie-Cleanup beseitigt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s
Der Commit 1bec054 hatte einen globalen controllerchange-Listener in
init(), der bei jedem Event location.reload() auslöste. In Kombination
mit der Zombie-Aufräumung (silent SKIP_WAITING) ergab das einen
Endlos-Loop: Seite lädt → Zombie erkannt → SKIP_WAITING → controller-
change → Reload → neue Seite mit frischem Zombie → usw.

Fix: Der controllerchange-Listener wird nur noch scoped aus reload()
heraus gesetzt ({ once: true }) — also genau dann, wenn der User auf
„Neu laden" geklickt hat und einen Reload tatsächlich will. Beim
silent Zombie-Cleanup gibt es keinen Listener, die Seite läuft
einfach nahtlos unter dem neuen (funktional identischen) SW weiter.

Regression-Test sichert ab, dass fireControllerChange() nach silent
SKIP_WAITING location.reload() NICHT aufruft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:12:19 +02:00
hsiegeln
1bec054ec6 fix(pwa): Zombie-waiting-SW via GET_VERSION erkennen (Live-Bug)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Das reine Workbox-Handshake-Pattern aus c2074c9 reicht für dieses
Deploy nicht. Live-Analyse mit Playwright ergibt reproduzierbar nach
dem Reload-Klick:
- active-SW: Version 1776527907402
- waiting-SW: Version 1776527907402 (bit-identisch!)
- Nur ein einziger shell-Cache
- Server-Response: gleiche Version
→ Toast kommt bei jedem Reload erneut.

Vermutung: Race zwischen Chromium-SW-Update-Check (der parallel
zum SKIP_WAITING läuft) und activate. Der Browser hält den zweiten
Installation-Versuch mit identischen Bytes im waiting-Slot.

Fix: SW bekommt GET_VERSION-Handler, Client fragt via MessageChannel
active und waiting nach Version. Bei Gleichheit räumt er den Zombie
stumm auf (SKIP_WAITING ohne Toast), bei Versions-Unterschied
zeigt er den Toast. Der refreshing-Flag-Reload-Guard aus c2074c9
bleibt erhalten.

Industry-Standard-Pattern bleibt die Basis; GET_VERSION ist ein
defensiver Zusatz für einen reproduzierbaren Browser-Edge-Case,
den Workbox nicht abfängt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:06:36 +02:00
hsiegeln
c2074c9768 refactor(pwa): auf Workbox-Standard vereinfacht, refreshing-Flag
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Der Zombie-Version-Check (858d4c1) ging über das Standard-Handshake-
Pattern hinaus. User will Industry-Standard: Workbox/web.dev-Pattern
ohne GET_VERSION-Sonderlocke.

Änderungen:
- service-worker.ts: GET_VERSION-Handler entfernt. SW reagiert nur
  noch auf SKIP_WAITING.
- pwa.svelte.ts: queryVersion + evaluateWaiting entfernt. init()
  zeigt Toast wieder schlicht bei registration.waiting (das ist
  kanonisch — bit-gleiche Bytes erzeugen keinen waiting-Slot).
- controllerchange-Listener wandert nach init() mit refreshing-Flag
  (CRA-Idiom): verhindert Doppel-Reload, wenn User zusätzlich F5
  drückt, und stellt sicher, dass der Listener in _jeder_ Session
  aktiv ist, nicht erst nach dem ersten reload()-Call.
- pwa-store.test.ts: Tests decken jetzt waiting→Toast, no-waiting→
  kein Toast, Handshake, refreshing-Flag und Sofort-Reload ab.

Der Zombie-Edge-Case (Browser-Quirk mit bit-identischem waiting-SW)
wird sich nach einmaligem Klick auflösen — erwarteter Trade-off
gegenüber der eingesparten Komplexität.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:57:51 +02:00
hsiegeln
858d4c1622 fix(pwa): Zombie-Waiting-SW erkennen und stumm aufräumen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m23s
Der vorige Fix (3d6f639) hat den Endlos-Toast "Neue Kochwas-Version
verfügbar" im Happy-Path beseitigt, aber das Grundproblem blieb:
pwaStore.init() hat blind `registration.waiting` als Update-Signal
verwendet.

Beobachtet auf der Live-PWA: Nach dem Reload-Klick existiert
registration.waiting weiter — als bit-identischer Zombie zum aktiven
SW (nur ein einziger shell-Cache `kochwas-shell-<version>`, Server-
Fetch liefert dieselbe Version-Konstante wie der active-SW). Der
Browser räumt diesen waiting-Slot nicht von selbst auf. Ergebnis:
beim nächsten init() steht `registration.waiting` wieder, Toast
kommt wieder.

Fix: SW bekommt einen GET_VERSION-MessageHandler. pwaStore fragt
active und waiting per MessageChannel nach ihrer Version. Sind sie
gleich, schickt er SKIP_WAITING silent an den Zombie und zeigt
KEINEN Toast. Nur bei echter Versions-Differenz erscheint das Update-
Angebot. Der alte onUpdateFound-Pfad geht den gleichen Weg.

Regression-Test: tests/unit/pwa-store.test.ts deckt Zombie-, Echt-
Update- und Fallback-Fall (alter SW ohne GET_VERSION) ab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:48:55 +02:00
hsiegeln
42f79f122b fix(api): PATCH akzeptiert servings_default=0
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Das Schema hatte positive() statt nonnegative() — damit schlug
jedes Save fehl, bei dem der Importer keine Portionsangabe finden
konnte und 0 eingesetzt hatte (z.B. bei rezeptwelt.de-Rezepten).
Alle anderen Int-Felder im gleichen Schema nutzen nonnegative()
konsistent; servings_default war der Ausreißer. DB-Spalte erlaubt
0 ohnehin, insertRecipe akzeptiert 0 → nur die PATCH-Validierung
hat unnötig blockiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:30:57 +02:00
hsiegeln
3d6f6393b3 fix(pwa): Endlos-Loop "Neue Version verfügbar" beseitigt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Der SW rief bisher im Install-Handler self.skipWaiting() auf —
der neue SW übersprang damit die "waiting"-Phase und aktivierte
sofort. pwaStore.onUpdateFound feuerte trotzdem auf statechange=
"installed" + vorhandenem controller und setzte updateAvailable=
true. Ergebnis: Toast erschien, obwohl der SW bereits übernommen
hatte, und der Klick auf "Neu laden" löste durch das Timing einen
neuen Update-Zyklus aus → Endlosschleife, v.a. im Incognito-Mode
wo jede Session neu installiert.

Jetzt klassisches Pattern: SW wartet in "installed"-Zustand bis
der User den Toast bestätigt; pwaStore.reload() postet
SKIP_WAITING an den wartenden SW, lauscht auf controllerchange
und reloadet dann erst. Ohne diese Trennung ist der Toast
semantisch kaputt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:27:04 +02:00
hsiegeln
0ede62dc8a docs(pwa): CLAUDE.md, OPERATIONS.md, ARCHITECTURE.md aktualisiert
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
CLAUDE.md: zwei neue Gotchas (SW nur HTTPS, Icon-Rendering) +
Erweiterung der "Dateien, die man anfasst"-Liste um SW-Pfade und
Client-Stores.

OPERATIONS.md: neuer Abschnitt "PWA / Offline-Modus" mit Caches,
Sync-Verhalten, Debug-Pfad und E2E-Test-Kommando.

ARCHITECTURE.md: neuer Abschnitt "Service Worker (PWA)" mit
Zuständigkeiten, Cache-Strategien, Message-Protokoll, Stores und
Komponenten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:09:54 +02:00
hsiegeln
1a4f7b5f20 test(pwa): E2E für Offline-Navigation, -Toast, -Indikator
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 28s
Playwright-Spec mit drei Tests:
- Offline-Navigation zu einem Rezept-Detail (Cache-Lesefall)
- Schreib-Aktion offline zeigt Toast (Favorit-Klick → Fehler-Toast)
- SyncIndicator zeigt "Offline"-Pill bei deaktiviertem Netzwerk

Seed-Fixture legt per /api/recipes/blank ein Rezept an, falls die
DB leer ist. waitForSync pollt navigator.serviceWorker.ready und
wartet zusätzlich 3 s für den initialen Pre-Cache.

Profil-Fixture (worker-scoped) erstellt bei Bedarf ein Test-Profil
und setzt es per addInitScript in localStorage, damit der Favorit-
Button den requireOnline-Guard erreicht (statt alertAction-Dialog).

SyncIndicator-Test ohne Reload: network.svelte.ts lauscht direkt auf
den 'offline'-Browser-Event, der bei context.setOffline(true) feuert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:07:21 +02:00
hsiegeln
528508a304 chore(test): Playwright für PWA-E2E-Tests aufgesetzt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m33s
@playwright/test + chromium als devDep. playwright.config.ts
startet npm run preview (production build nötig für SW), baseURL
localhost:4173, fullyParallel off (SW-Installations-Timing). Ein
Smoke-Test smoke.spec.ts öffnet / und prüft den Titel. test-results/
und playwright-report/ in .gitignore ergänzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:01:39 +02:00
hsiegeln
8bb208a613 feat(pwa): Admin-Tab "App" mit Install + Sync + Cache-Reset
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Neuer vierter Admin-Tab (Smartphone-Icon) mit drei Karten:
1. Installieren — fängt beforeinstallprompt (Android), zeigt
   iOS-Teilen-Hinweis, sonst Info "nicht verfügbar".
2. Offline-Synchronisation — Status + "Jetzt synchronisieren"-
   Button, disabled wenn offline.
3. Cache — "Offline-Cache leeren" löscht alle kochwas-*-Caches
   via caches.keys() + delete.

install-prompt.svelte.ts hält das deferred-Event und die Plattform
(android/ios/other) per UA-Detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:57:49 +02:00
hsiegeln
3906781c4e feat(pwa): Schreib-Aktionen zeigen Offline-Toast statt stillem Fail
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Neuer Helper requireOnline(action) prüft vor jedem Schreib-Fetch
den Online-Status. Offline: ein Toast erscheint ("Die Aktion braucht
eine Internet-Verbindung."), Aktion bricht sauber ab. Der Button-
State bleibt unverändert (kein optimistisches Update, das gleich
wieder zurückgedreht werden müsste).

Eingebaut in Rezept-Detail (8 Handler), Register (2), Wunschliste
(2), Admin Domains/Profile/Backup, Home-Dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:54:03 +02:00
hsiegeln
447ff2be32 fix(pwa): SW-Manifest trackt nur wirklich gecachte Rezepte
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Vorher: saveCachedIds(currentIds) hat alle Server-IDs gespeichert,
auch wenn cacheRecipe still fehlschlug — beim nächsten sync-check
wurden die fehlenden übersprungen, offline gab's 404. Jetzt:
cacheRecipe + addToCache geben boolean zurück, nur die wirklich
erfolgreich gecachten IDs landen im Manifest. Bei Update-Sync wird
das Manifest aus (cached - toRemove + successful) berechnet.

Zusätzlich console.warn in addToCache, damit Cache-Misses auf dem
Pi debuggbar sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:50:26 +02:00
hsiegeln
51a88a4c58 feat(pwa): SW Pre-Cache-Orchestrator mit Fortschritt + Delta-Sync
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Message-Handler für sync-start (initial: alle Rezepte cachen) und
sync-check (delta: nur neue nachladen, gelöschte räumen). Vor dem
Sync ein Storage-Quota-Check (<100 MB frei → abbrechen mit Fehler-
Broadcast). Concurrency-Pool mit 4 parallelen Downloads pro
Rezept (HTML, API-JSON, Bild). Fortschritt per postMessage an
alle Clients, die über den sync-status-Store den SyncIndicator
füllen. Das Cache-Manifest wird als JSON-Response unter
/__cache-manifest__ im kochwas-meta Cache persistiert.

Client triggert beim App-Start entweder sync-check (bereits
kontrollierter SW) oder sync-start (erstmaliger SW-Install).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:44:48 +02:00
hsiegeln
582d902c62 feat(pwa): Service-Worker-Gerüst mit Shell-Cache + Fetch-Dispatch
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
src/service-worker.ts installiert die App-Shell-Assets (build +
files aus $service-worker) beim install-Event in kochwas-shell-
<version>, räumt alte Shell-Caches beim activate und dispatcht
jeden Fetch via resolveStrategy — shell/images cache-first, swr
stale-while-revalidate, network-only unangetastet. Pre-Cache-
Orchestrator kommt in Task 9.

Client-seitig registriert sw-register.ts den SW und verdrahtet
Messages vom SW in den sync-status-Store.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:38:09 +02:00
hsiegeln
7c8edb9b92 feat(pwa): Cache-Manifest-Diff-Funktion + Tests
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Pure Funktion diffManifest(current, cached) → {toAdd, toRemove}.
Vom SW beim Update-Sync genutzt: neue Rezept-IDs nachladen,
gelöschte aus dem Cache räumen. 5 Tests decken add/remove/
beides/unchanged/empty-cache ab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:34:39 +02:00
hsiegeln
d38992661c feat(pwa): Cache-Strategy-Entscheider + Unit-Tests
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Reine TS-Funktion resolveStrategy({url, method}) → 'shell' | 'swr'
| 'images' | 'network-only'. Kernregel:
  - Schreib-Methoden + import/preview/search/web → network-only
  - /images/* → images (cache-first)
  - /_app/, manifest, Icons, favicon, robots → shell (cache-first)
  - Alles andere same-origin-GET → swr
7 Tests decken alle Buckets ab. Wird vom SW in Task 8 aufgerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:32:30 +02:00
hsiegeln
02df0331b7 feat(pwa): SyncIndicator-Pill mit Overlay-Karte
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Bottom-right Pill zeigt Sync-Fortschritt (Sync N/M) oder Offline-
Status. Klick öffnet Overlay mit "Zuletzt synchronisiert: vor
N Min" + manuellem Refresh-Button (postMessage type=sync-check an
den SW). prefers-reduced-motion noch nicht gehandhabt — Spin-Icon
dreht sich bewusst; kein UX-Schaden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:29:31 +02:00
hsiegeln
d08cefa5c9 feat(pwa): Sync-Status-Store mit localStorage-Persistierung
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Spiegelt SW-Messages (sync-start/progress/done/error) in einen
Svelte-State. lastSynced wird in localStorage persistiert, damit
der User nach einem Reload sieht, wann zuletzt synchronisiert
wurde. Wird vom SyncIndicator und der Admin-App-Tab konsumiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:25:35 +02:00
hsiegeln
0c66bd677e feat(pwa): Toast-Store + Renderer
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Toast-Queue als $state-Store mit Auto-Dismiss nach 3 s und manuellem
dismiss(id). Drei Kinds: info/error/success (Farbe). Renderer als
<Toast /> im Root-Layout, fix-positioniert oben mittig. Wird
vom Offline-Check der Schreib-Aktionen genutzt und später auch für
Sync-Abschluss-Meldungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:21:14 +02:00
hsiegeln
04641355df feat(pwa): Online-Status-Store
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m31s
Reaktiver Store basierend auf navigator.onLine und den window-
Events online/offline. Kein aktives Heuristik-Probing — für
unseren Offline-PWA-Use-Case reicht der Browser-Status. Wird von
SyncIndicator und require-online-Helper konsumiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:17:11 +02:00
hsiegeln
0b12aa027f feat(pwa): PNG-Icons 192/512 + Manifest maskable-fähig
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m35s
Android Chrome bevorzugt für den Home-Screen rasterbare PNG-Icons
über reines SVG. 192×192 und 512×512 werden aus static/icon.svg
per Sharp-Skript gerendert (npm run render:icons) und committet,
damit CI keine zusätzliche Abhängigkeit hat. Manifest referenziert
alle drei Icons mit purpose "any maskable" → rund-/squircle-sicher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:12:40 +02:00
hsiegeln
60f6db9091 docs(plan): v1.1 Offline-PWA Implementierungsplan
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>
2026-04-18 16:07:11 +02:00
hsiegeln
303939a6ff docs(spec): v1.1 Offline-PWA Design
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 46s
Ergebnis des Brainstormings. Entscheidungen:
- Alle Rezepte + Bilder synchronisieren (~60 MB, ~200 Rezepte)
- SvelteKits eingebauter Service Worker, keine externe PWA-Abhängigkeit
- Hintergrund-Pre-Cache ohne Blocker, sichtbarer Fortschritt im
  dezenten Sync-Indikator unten rechts
- Stale-While-Revalidate für Rezept-Daten, Cache-First für Shell+Bilder
- Schreib-Aktionen offline: proaktiver Check + Toast, keine Queue
- Neuer Admin-Tab "App" für Install-Button, Sync-Status, Reset
- Unit-Tests für Cache-Strategy/Diff, Playwright-E2E für Offline-Flows

Bereit für Nutzer-Review und anschließende Plan-Erstellung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:59:39 +02:00
hsiegeln
2807dd1cab feat(import): manuelle URL-Importe von allen Domains zulassen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m14s
Der User pastet bewusst eine URL und erwartet, dass der Import
klappt — die Whitelist-Prüfung (DOMAIN_BLOCKED) im previewRecipe
war da nur Reibung. Die Whitelist bleibt für die Web-Suche relevant
(dort muss das Crawl-Feld eingeschränkt werden), für Imports nicht
mehr.

Dropped: isDomainAllowed + whitelist.ts, DOMAIN_BLOCKED-Code in
ImporterError, die zugehörige Branch in mapImporterError. Tests
entsprechend angepasst: statt "DOMAIN_BLOCKED wenn nicht whitelisted"
prüft der Preview-Test jetzt "klappt auch ohne Whitelist-Eintrag".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:18:46 +02:00
hsiegeln
7233cc3a13 style(wishlist): Chip-Label "Beliebteste" → "Meist gewünscht"
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
"Beliebteste" ist mehrdeutig (Sterne? Favoriten? Wünsche?). Der
Sort-Key popular sortiert nach wanted_by_count DESC, also der
Anzahl Profile mit dem Rezept auf ihrer Wunschliste. Das Label
macht das jetzt eindeutig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:12:22 +02:00
hsiegeln
297281e201 style(wishlist): Sortierung als Pill-Chips wie auf der Startseite
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Das native <select> fiel stilistisch aus dem App-Bild. Jetzt
identisch zur "Alle Rezepte"-Sortierung auf der Startseite: drei
grüne Pill-Chips (Beliebteste / Neueste / Älteste), aktive Pille
invertiert. Verhalten gleich, nur die Optik angepasst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:10:52 +02:00
hsiegeln
194aee269e feat(recipe): Pulse-Animation beim Aktivieren Favorit/Wunschliste
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Kurzer Scale-Bounce plus ausklingender Ring in der Aktionsfarbe
(rot für Favorit, grün für Wunschliste), sobald der Button eine
Markierung setzt. Beim Wieder-Abwählen bleibt es ruhig — hilft
die Bestätigung visuell abzusetzen.

Die Animation wird per tick()-Zwischenschritt (false → tick → true)
gestartet, damit mehrfache Klicks innerhalb weniger hundert ms die
Animation neu triggern. prefers-reduced-motion schaltet den Effekt
aus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:06:15 +02:00
hsiegeln
361164febd style(wishlist): Herz durch Utensils-Icon ersetzt
Das Herz auf der Wunschliste doppelte sich semantisch mit dem
Favoriten-Herz in der Rezeptansicht. Jetzt deckungsgleich mit dem
Wunschlisten-Icon in der Rezept-Action-Bar: Utensils. Aktiv-Farbe
von rot auf das Kochwas-Grün gewechselt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:06:07 +02:00
48 changed files with 5001 additions and 168 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ data/
.env .env
.env.local .env.local
*.log *.log
test-results/
playwright-report/
.playwright-mcp/

View File

@@ -17,6 +17,8 @@ Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://k
| **Migrations** | Werden via Vite `import.meta.glob('./migrations/*.sql', {eager, query:'?raw'})` gebundelt. Neue Migration einfach als `00N_name.sql` ablegen, kein Copy-in-Dockerfile nötig. | | **Migrations** | Werden via Vite `import.meta.glob('./migrations/*.sql', {eager, query:'?raw'})` gebundelt. Neue Migration einfach als `00N_name.sql` ablegen, kein Copy-in-Dockerfile nötig. |
| **$lib/server in Client** | Svelte-Import aus `$lib/server/*` in einem `.svelte`-Komponenten-Script bricht den Build. Pures JS/TS, das beidseitig funktioniert (z. B. Portionen-Scaler), gehört nach `$lib/`, nicht `$lib/server/`. | | **$lib/server in Client** | Svelte-Import aus `$lib/server/*` in einem `.svelte`-Komponenten-Script bricht den Build. Pures JS/TS, das beidseitig funktioniert (z. B. Portionen-Scaler), gehört nach `$lib/`, nicht `$lib/server/`. |
| **Preview-Bilder** | `recipe.image_path` kann **absolute URL** (Preview-Modus) oder **lokaler Filename** sein. `RecipeView.svelte` prüft mit `/^https?:\/\//i`. | | **Preview-Bilder** | `recipe.image_path` kann **absolute URL** (Preview-Modus) oder **lokaler Filename** sein. `RecipeView.svelte` prüft mit `/^https?:\/\//i`. |
| **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. |
## Dateien, die man typischerweise anfasst ## Dateien, die man typischerweise anfasst
@@ -27,6 +29,9 @@ Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://k
- `src/lib/server/search/searxng.ts` — Web-Suche + Thumbnail-Enrichment + SQLite-Cache - `src/lib/server/search/searxng.ts` — Web-Suche + Thumbnail-Enrichment + SQLite-Cache
- `src/lib/server/recipes/importer.ts` — JSON-LD → Recipe, orchestriert Bild-Download - `src/lib/server/recipes/importer.ts` — JSON-LD → Recipe, orchestriert Bild-Download
- `src/lib/server/db/migrations/*.sql` — Schema; bei Änderung immer **neue** Migration statt bestehende bearbeiten - `src/lib/server/db/migrations/*.sql` — Schema; bei Änderung immer **neue** Migration statt bestehende bearbeiten
- `src/service-worker.ts` — Service-Worker-Orchestrator (Shell-Cache + Pre-Cache + SWR)
- `src/lib/sw/` — reine Logik (Cache-Strategy-Entscheider, Diff-Manifest) für Unit-Tests
- `src/lib/client/*.svelte.ts` — Frontend-Stores (Network, Sync-Status, Toast, Install-Prompt)
## Arbeitsweise (wie wir es machen) ## Arbeitsweise (wie wir es machen)

View File

@@ -106,6 +106,35 @@ Bei Schema-Änderung:
- **Keine Svelte-Component-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird manuell getestet) - **Keine Svelte-Component-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird manuell getestet)
- **Vor Commit**: `npm test && npm run check` muss grün sein. - **Vor Commit**: `npm test && npm run check` muss grün sein.
### Service Worker (PWA)
`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 beim Initial-Sync), über paginierten Fetch von `/api/recipes/all`.
- **Delta-Sync** beim App-Start (diff vs. Cache-Manifest, nur Delta laden).
- **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = SWR, Bilder = cache-first.
- **Message-Protokoll** (`sync-start`, `sync-progress`, `sync-done`, `sync-error`) zwischen SW und Client.
Reine Logik-Einheiten (testbar, Unit-Tests in `tests/unit/`):
- `src/lib/sw/cache-strategy.ts``resolveStrategy({url, method})``'shell' | 'swr' | 'images' | 'network-only'`
- `src/lib/sw/diff-manifest.ts``diffManifest(current, cached)``{toAdd, toRemove}`
Client-Stores (SSR-safe via typeof-Guards):
- `src/lib/client/network.svelte.ts``navigator.onLine` + Events.
- `src/lib/client/sync-status.svelte.ts` — SW-Message-Spiegel, `lastSynced` in localStorage.
- `src/lib/client/toast.svelte.ts` — Toast-Queue für Offline-Fehler + Sync-Meldungen.
- `src/lib/client/install-prompt.svelte.ts` — fängt `beforeinstallprompt`, erkennt Plattform.
- `src/lib/client/sw-register.ts` — registriert den SW, leitet Messages an den Sync-Status-Store.
- `src/lib/client/require-online.ts` — Helper für Schreib-Aktionen (Toast statt stillem Fail).
UI-Komponenten:
- `src/lib/components/SyncIndicator.svelte` — Pill unten rechts (Sync-Fortschritt / Offline-Status).
- `src/lib/components/Toast.svelte` — Top-Center-Toast-Renderer.
Admin-UI: `src/routes/admin/app/+page.svelte` mit Install-Button, manuellem Sync-Trigger, Cache-Reset.
E2E-Tests: `tests/e2e/offline.spec.ts` — Playwright setzt das Netzwerk offline und prüft Navigation/Toast/Indikator-Verhalten.
## Was später kommt (laut Spec, aktuell nicht implementiert) ## Was später kommt (laut Spec, aktuell nicht implementiert)
- LLM-Fallback für nicht-JSON-LD-Seiten - LLM-Fallback für nicht-JSON-LD-Seiten

View File

@@ -146,3 +146,28 @@ Siehe `.env.example` im Repo.
- **Thumbnail-Cache in SQLite** → `003_thumbnail_cache.sql` + `searxng.ts` - **Thumbnail-Cache in SQLite** → `003_thumbnail_cache.sql` + `searxng.ts`
Git-Log ist die Wahrheit; diese Datei ist eine Orientierung. Git-Log ist die Wahrheit; diese Datei ist eine Orientierung.
## PWA / Offline-Modus
Kochwas ist eine installierbare PWA. Erkennbar an:
- `static/manifest.webmanifest` (Manifest + Icons: SVG + 192×192 + 512×512, alle maskable)
- `src/service-worker.ts` (Cache + Sync)
Caches im Browser (siehe DevTools → Application → Cache Storage):
- `kochwas-shell-<version>` — App-Shell (JS/CSS/Static-Icons), cache-first
- `kochwas-data-v1` — Rezept-HTMLs + API-JSON (SWR)
- `kochwas-images-v1` — Bilder (cache-first)
- `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`)
Sync-Verhalten:
- **Initial-Sync** (nach erstem Install): SW lädt alle Rezepte + Bilder im Hintergrund. Fortschritt im `SyncIndicator`-Pill unten rechts.
- **Update-Sync** (bei jedem App-Start online): Diff gegen Cache-Manifest, nur Delta nachladen, gelöschte IDs räumen.
- **Storage-Quota-Check**: < 100 MB frei → abbrechen mit Fehler-Toast.
Bei SW-Problemen Debug-Pfad:
1. Admin → „App"-Tab → „Offline-Cache leeren" (destructive, zweistufig bestätigt)
2. Alternative: DevTools → Application → Service Workers → Unregister, dann Seite neu laden.
E2E-Tests (Playwright): `npm run test:e2e`. Setzt `npm run build` voraus (Playwright startet automatisch `npm run preview`).
Icons einmalig rendern: `npm run render:icons` (schreibt nach `static/icon-*.png`, committen).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
# Offline-PWA v1.1 — Design-Spec
> **Stand**: 2026-04-18 — Brainstorming-Ergebnis. Vor der Plan-Erstellung vom Nutzer zu bestätigen.
## Ziel
Kochwas als installierbare PWA mit vollständigem Lese-Offline-Modus. Alle Rezepte (bei ~200 erwartet: ca. 60 MB inkl. Bilder) werden automatisch lokal synchronisiert. Schreib-Aktionen bleiben online-only. Keine Backend-Änderungen.
## Design-Entscheidungen (aus Brainstorming)
| Entscheidung | Gewähltes Vorgehen |
|---|---|
| Sync-Umfang | **Alle Rezepte + alle Bilder** (nicht nur Favoriten/Wunschliste). Einheitliches Mental-Modell "alles da". |
| Installierbarkeit | **Volles PWA-Manifest + Icons** — Home-Screen-App auf Android/iOS. |
| Offline-Indikator | **Dezent**, fix unten rechts als Pill. Schreib-Buttons zeigen Toast bei Fehler. |
| Pre-Cache-Timing | **Im Hintergrund** nach erstem Besuch. Kein blockierender Ladescreen. Sichtbarer Fortschritt. |
| Update-Strategie | **Bei jedem App-Start wenn online** — diff gegen Cache-Manifest, Delta nachladen. |
| SW-Technologie | **SvelteKits eingebauter Service Worker** (`src/service-worker.ts`, `$service-worker`-Modul). Kein `vite-plugin-pwa`. |
| Offline-Schreib-Queue | **Nicht Teil dieser Version**. Offline-Klicks zeigen Toast und bleiben ohne Wirkung. Komplexität verschoben auf v1.2+. |
## Architektur
### Cache-Buckets
Drei Buckets, drei Strategien:
1. **`kochwas-shell-v{hash}`** — App-Shell (Build-Output: JS, CSS, Static-Icons aus `$service-worker`'s `build` + `files`). **Cache-First**. Bei Deploy neue Version → alter Cache wird in `activate` gelöscht.
2. **`kochwas-data-v1`** — Rezept-HTMLs (`/recipes/[id]`) + API-Reads (`/api/recipes/*`, `/api/wishlist`, `/api/domains`). **Stale-While-Revalidate**. Cache-Antwort sofort, Netz-Fetch parallel für nächsten Besuch.
3. **`kochwas-images-v1`** — `/images/*`. **Cache-First**. Filenames sind SHA-256-Hashes → ändert sich das Bild, ändert sich die URL, neue Einträge, alte räumt der Diff-Sync weg.
### Network-Only (nie cachen)
- Alle `POST/PUT/PATCH/DELETE` Requests
- `GET /api/recipes/import`, `/api/recipes/preview`, `/api/recipes/search/web` — reine Netz-Features, offline sinnfrei
- `GET /api/recipes/blank` gibt es nicht (Blank ist POST)
### Pre-Cache-Flow (Initial + Update)
**Initial (nach SW-Activate, einmalig)**:
1. Client postet `{ type: 'sync-start' }` an SW.
2. SW fetcht `/api/recipes/all?sort=name&limit=50&offset=N` seitenweise bis weniger als 50 Treffer kommen (Endpoint cappt aktuell auf 50 pro Request, siehe `/api/recipes/all/+server.ts`).
3. Alle IDs in Cache-Manifest-Entry schreiben (`kochwas-meta` cache, key `/cache-manifest`).
4. Für jede ID: parallel (max. 4 gleichzeitig) cachen:
- `GET /recipes/{id}``data`-Bucket
- `GET /api/recipes/{id}``data`-Bucket
- Aus der JSON-Response `image_path` extrahieren, wenn vorhanden `GET /images/{image_path}``images`-Bucket
5. Nach jedem erfolgreichen Eintrag: `postMessage({ type: 'sync-progress', current, total })` an alle Clients.
6. Am Ende: `postMessage({ type: 'sync-done', lastSynced: Date.now() })`.
**Update (bei jedem App-Start online)**:
1. Client postet `{ type: 'sync-check' }` an SW.
2. SW fetcht `/api/recipes/all` frisch.
3. Diff gegen Cache-Manifest:
- Neue IDs → cachen wie oben (nur Delta).
- Gelöschte IDs → aus `data`- und `images`-Bucket räumen.
4. Wenn Delta leer → `sync-done` mit unverändertem Zähler.
**Abbruch-Resilienz**: SW hält State in Cache-Manifest; abgebrochen mittendrin → nächster Start sieht unvollständiges Manifest und holt das Fehlende nach. Idempotent.
**Editierte Rezepte (gleiche ID, neuer Inhalt)**: Der Diff-Sync sieht keine Änderung (ID existiert ja). Der Refresh passiert stattdessen über Stale-While-Revalidate: wenn der User das Rezept online öffnet, liefert der Cache zuerst, der parallele Netz-Fetch aktualisiert den Cache-Eintrag. Der User sieht die Änderung also **beim übernächsten Öffnen**. Akzeptabel für eine Familien-App — wenn jemand „Salz auf 5 g" editiert, ist das nicht zeitkritisch. Bilder-Updates (neuer Image-Path durch andere Hash-URL) funktionieren automatisch: API-JSON aktualisiert sich per SWR, neue URL wird beim nächsten Bildrequest vom SW gecacht; alter Image-Cache-Entry bleibt als Orphan bis zum nächsten `diffManifest`-Lauf, der auch nach Orphan-Images schaut.
**Concurrency**: 4 parallele Requests max — schont den Raspberry Pi unter Last.
**Storage-Check**: Vor dem Initial-Sync `navigator.storage.estimate()`. Bei verfügbarem Quota < 100 MB → Toast: "Nicht genug Speicher für Offline-Modus". Hintergrund-Sync läuft trotzdem, bricht bei Quota-Fehler einfach ab.
### Sync-Status-Store
`src/lib/client/sync-status.svelte.ts`:
```ts
type SyncState =
| { kind: 'idle' }
| { kind: 'syncing'; current: number; total: number }
| { kind: 'error'; message: string };
export const syncStatus = {
state: $state<SyncState>({ kind: 'idle' }),
lastSynced: $state<number | null>(null),
// Abonniert SW-Messages, dispatcht State
};
```
Gefüllt über `navigator.serviceWorker.addEventListener('message', ...)`. Persistiert `lastSynced` in localStorage (`kochwas.sw.lastSynced`).
### Online-Status-Store
`src/lib/client/network.svelte.ts`:
```ts
export const network = {
online: $state(navigator.onLine),
// initialisiert Listener auf window 'online'/'offline'
};
```
Keine heuristischen Fetches — `navigator.onLine` ist für unsere Zwecke gut genug.
### UI-Komponenten
**`<SyncIndicator />`** — fix positioniert unten rechts, ~90×30 px Pill. Drei States:
- Sync läuft: grüner Spinner + `Sync 47/200`
- Offline: grauer Pill mit `Offline`
- Online, alles synchron: `display: none`
Tap/Klick öffnet kleine Overlay-Karte:
- "Zuletzt synchronisiert: vor 3 Min · 200 Rezepte im Cache"
- "Jetzt aktualisieren"-Button (triggert `sync-check`)
**`<Toast />`** — in `+layout.svelte` am Top eingehängt. Kurze, nicht-blockierende Meldungen. Store-API:
```ts
toastStore.error('Nicht verbunden');
toastStore.info('Synchronisiert — 200 Rezepte');
```
Auto-Dismiss nach 3 s, manuell ×-klickbar.
**Admin-Tab „App"** (`/admin/app`) — vierter Tab im Admin-Layout:
- Install-Button: feuert das gespeicherte `beforeinstallprompt`-Event. Auf iOS (UA-Detect): Info-Text „Teilen → Zum Home-Bildschirm hinzufügen".
- Sync-Status: `Synchronisiert 200/200 Rezepte (zuletzt 15:42)`.
- „Jetzt aktualisieren"-Button.
- „Offline-Cache leeren"-Button (destructive, zweistufig bestätigt) — für Debugging/Reset.
### Schreib-Aktionen-Verhalten
Betroffene Buttons in:
- `/recipes/[id]/+page.svelte`: Rating, Favorit, Wunschliste, Cooked, Kommentar, Titel, Edit-Save, Löschen, Bildschirm-Wake-Lock
- `/recipes/+page.svelte` (Register): Import, Blank-Create
- `/wishlist/+page.svelte`: Wunschliste-Toggle, Für-alle-entfernen
- `/admin/*/+page.svelte`: Domain-CRUD, Profile-CRUD, Backup
Pattern pro Klick:
```ts
if (!network.online) {
toastStore.error('Nicht verbunden — die Aktion speichert nicht.');
return;
}
// ... dann normal fetch ...
```
Alternative: Fetch versuchen, bei `TypeError: Failed to fetch` im catch toasten. Beides ist OK. Design-Entscheidung: **proaktiver Check** — klarere UX, keine falschen optimistischen UI-Updates.
### PWA-Manifest-Ergänzungen
`static/manifest.webmanifest`:
```json
{
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
```
Icons werden lokal einmalig aus `static/icon.svg` gerendert (Inkscape oder `rsvg-convert`) und committed. Keine CI-Abhängigkeit.
## Dateien
### Neu
- `src/service-worker.ts` — SW-Hauptdatei (install/activate/fetch/message-Handler, Pre-Cache-Orchestrator)
- `src/lib/client/sync-status.svelte.ts` — Sync-Status-Store
- `src/lib/client/network.svelte.ts` — Online-Status-Store
- `src/lib/client/toast.svelte.ts` — Toast-Store
- `src/lib/components/SyncIndicator.svelte` — bottom-right Pill + Overlay-Karte
- `src/lib/components/Toast.svelte` — Toast-Renderer
- `src/routes/admin/app/+page.svelte` — Admin-Tab „App"
- `static/icon-192.png`, `static/icon-512.png` — PWA-Icons (einmal gerendert, committed)
- `tests/integration/sw-cache-strategy.test.ts` — Unit-Tests für die Cache-Strategy-Entscheider + Diff-Logik
- `tests/e2e/offline.spec.ts` — Playwright: Offline-Navigation, Sync-Indikator, Schreib-Aktion-Toast
### Geändert
- `static/manifest.webmanifest` — PNG-Icons ergänzen, `purpose: "any maskable"`
- `src/routes/+layout.svelte` — SW registrieren, `<SyncIndicator />` + `<Toast />` einbinden, Network-Store initialisieren
- `src/routes/admin/+layout.svelte` — vierten Tab „App" mit Smartphone-Icon
- Alle Seiten mit Schreib-Buttons — proaktiver `network.online`-Check
### Nicht angefasst
- Backend (`src/lib/server/**`, `src/routes/api/**`) — reines Frontend-Feature
- Datenbank-Schema
- Deployment (Dockerfile, compose-Dateien)
## Test-Strategie
### Unit-Tests (vitest)
- `sync-status.svelte.ts`: State-Übergänge bei Messages
- `toast.svelte.ts`: Store-API, Auto-Dismiss
- `sw-cache-strategy.test.ts`:
- `resolveStrategy(url)` → gibt Strategy-Namen zurück (cache-first, swr, network-only)
- `diffManifest(currentIds, cachedIds)``{ toAdd, toRemove }`
- Concurrency-Queue: vier parallel, Gesamt-Reihenfolge idempotent
### E2E-Tests (Playwright, lokales Docker)
- **Install + Sync**: Seite öffnen, warten bis `sync-done`, Cache-Einträge überprüfen.
- **Offline-Lesen**: Netz aus (Playwright-API), Navigation `/``/recipes/[id]` → zurück, Rezept ist sichtbar.
- **Offline-Schreiben**: Netz aus, Favorit-Toggle klicken, Toast erscheint, Herz nicht gefüllt.
- **Update-Sync**: Im Browser ein neues Rezept via Register importieren, Tab neu laden, `sync-check` feuert, Rezept-ID-Liste gewachsen.
- **Sync-Indikator-Zustände**: Manuell getriggert, alle drei States visuell überprüfen.
### Manuelle Tests
- Android Chrome: beforeinstallprompt → Install-Button → Home-Screen-App startet
- Safari iOS: Teilen → Zum Home-Bildschirm, Start der App, Offline-Navigation
- Chrome DevTools → Application → Storage → Clear Site Data → Re-Load → Initial-Sync läuft durch
## Out of Scope (v1.1)
Bewusst raus, mögliche v1.2-Themen:
- **Background Sync für Schreib-Aktionen** — Rating/Kommentare offline speichern und später syncen. Braucht Konflikt-Resolution, schedule.sync-API, Duplikat-Erkennung.
- **Push-Benachrichtigungen** — "Jemand hat ein neues Rezept hinzugefügt". Viel Infrastruktur für wenig Nutzen.
- **Offline-Web-Suche** — nicht sinnvoll, braucht SearXNG.
- **Partial-Sync nach Profil** — alle Rezepte bleiben synchronisiert, keine Profil-spezifische Teilmenge.
## Risiken + Mitigation
| Risiko | Mitigation |
|---|---|
| Storage-Quota erschöpft | `navigator.storage.estimate()` vor Sync, Toast bei < 100 MB frei |
| SW-Deploy: alte Clients sehen alten Cache | Cache-Name inkl. Build-Hash, `activate` räumt alte Versionen |
| Alter SW blockiert Update | `skipWaiting()` + `clients.claim()` — neuer SW übernimmt sofort |
| Fetch-Loop (SW ruft sich selbst) | Exakte URL-Muster-Matching, keine Wildcards auf `/api/**` |
| iOS Safari vergisst Cache | Bekanntes iOS-Verhalten bei langer Inaktivität; Akzeptieren, nächster Start synct nach |
| SW nur auf HTTPS oder localhost | Produktion läuft unter `https://kochwas.siegeln.net` ✓. Dev-Server läuft auf HTTP — für SW-Tests braucht's entweder `npm run build && npm run preview` (baut auf localhost, SW registrierbar) oder die lokale Docker-Compose-Prod-Variante |

1152
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,22 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write ." "format": "prettier --write .",
"render:icons": "node scripts/render-icons.mjs",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0", "@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"jsdom": "^29.0.2",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7", "prettier-plugin-svelte": "^3.2.7",
"sharp": "^0.34.5",
"svelte": "^5.1.0", "svelte": "^5.1.0",
"svelte-check": "^4.0.5", "svelte-check": "^4.0.5",
"typescript": "^5.6.3", "typescript": "^5.6.3",

21
playwright.config.ts Normal file
View File

@@ -0,0 +1,21 @@
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
}
});

19
scripts/render-icons.mjs Normal file
View File

@@ -0,0 +1,19 @@
// 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`);
}

View File

@@ -0,0 +1,44 @@
// Captures the beforeinstallprompt event (Android Chrome) and holds it for
// manual triggering by the user. On iOS Safari this event does not exist —
// we detect the browser via UserAgent and show an info hint instead.
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 type for the Chrome-specific event
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
export const installPrompt = new InstallPromptStore();

View File

@@ -0,0 +1,14 @@
// 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();

View File

@@ -1,3 +1,19 @@
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
// skipWaiting im install-Handler, User bestätigt via Toast) mit
// zusätzlichem Zombie-Schutz.
//
// Warum der Zombie-Schutz nötig ist: Chromium hält auf diesem Deploy
// reproduzierbar nach einem SKIP_WAITING+Reload einen bit-identischen
// waiting-SW im Registration-Slot — wohl durch einen Race zwischen
// SW-Update-Check und activate. Der reine Workbox-Standard würde den
// als „neues Update" interpretieren und den Toast bei jedem Reload
// erneut zeigen. Wir fragen darum per MessageChannel GET_VERSION an
// beiden SWs, vergleichen und räumen identische Bytes still auf.
//
// Kritisch: Der Reload beim controllerchange darf NUR durch User-Klick
// passieren, nicht automatisch beim silent Cleanup — sonst ergibt der
// Zombie-Refresh einen Endlos-Reload-Loop, weil der Browser jede neue
// Seite wieder mit frischem Zombie ausstattet.
class PwaStore { class PwaStore {
updateAvailable = $state(false); updateAvailable = $state(false);
private registration: ServiceWorkerRegistration | null = null; private registration: ServiceWorkerRegistration | null = null;
@@ -5,6 +21,7 @@ class PwaStore {
async init(): Promise<void> { async init(): Promise<void> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
try { try {
this.registration = await navigator.serviceWorker.ready; this.registration = await navigator.serviceWorker.ready;
} catch { } catch {
@@ -12,10 +29,8 @@ class PwaStore {
} }
if (!this.registration) return; if (!this.registration) return;
// Wenn beim Mount schon ein neuer SW installiert und aktiv wartet, if (this.registration.waiting && this.registration.active) {
// zeigen wir den Toast direkt an. await this.evaluateWaiting(this.registration.waiting, this.registration.active);
if (this.registration.waiting) {
this.updateAvailable = true;
} }
this.registration.addEventListener('updatefound', () => this.onUpdateFound()); this.registration.addEventListener('updatefound', () => this.onUpdateFound());
@@ -31,17 +46,47 @@ class PwaStore {
const installing = this.registration?.installing; const installing = this.registration?.installing;
if (!installing) return; if (!installing) return;
installing.addEventListener('statechange', () => { installing.addEventListener('statechange', () => {
// 'installed' UND ein laufender controller = Update für bestehenden Tab. if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
// (Ohne controller wäre das die erste Installation, kein Update.) const active = this.registration?.active;
if (installing.state === 'installed' && navigator.serviceWorker.controller) { if (active && active !== installing) {
void this.evaluateWaiting(installing, active);
} else {
this.updateAvailable = true; this.updateAvailable = true;
} }
}); });
} }
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
const [waitingVersion, activeVersion] = await Promise.all([
queryVersion(waiting),
queryVersion(active)
]);
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
// Bit-identischer Zombie: silent aufräumen, KEIN reload — die Seite
// läuft nahtlos unter dem neuen SW weiter (funktional identisch).
waiting.postMessage({ type: 'SKIP_WAITING' });
return;
}
// Versions-Unterschied oder unbekannt: User entscheidet via Toast.
this.updateAvailable = true;
}
reload(): void { reload(): void {
this.updateAvailable = false; this.updateAvailable = false;
const waiting = this.registration?.waiting;
if (!waiting) {
// Kein wartender SW — reicht ein normaler Reload.
location.reload(); location.reload();
return;
}
// Klassisches Pattern: User-Klick → SKIP_WAITING → controllerchange
// feuert, wenn der neue SW übernimmt → dann reloaden wir einmalig.
navigator.serviceWorker.addEventListener(
'controllerchange',
() => location.reload(),
{ once: true }
);
waiting.postMessage({ type: 'SKIP_WAITING' });
} }
dismiss(): void { dismiss(): void {
@@ -49,4 +94,22 @@ class PwaStore {
} }
} }
function queryVersion(sw: ServiceWorker): Promise<string | null> {
return new Promise((resolve) => {
const channel = new MessageChannel();
const timer = setTimeout(() => resolve(null), 1500);
channel.port1.onmessage = (e) => {
clearTimeout(timer);
const v = (e.data as { version?: unknown } | null)?.version;
resolve(typeof v === 'string' ? v : null);
};
try {
sw.postMessage({ type: 'GET_VERSION' }, [channel.port2]);
} catch {
clearTimeout(timer);
resolve(null);
}
});
}
export const pwaStore = new PwaStore(); export const pwaStore = new PwaStore();

View File

@@ -0,0 +1,10 @@
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;
}

View File

@@ -0,0 +1,33 @@
// 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);
}
});
// 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' });
});
}
}

View File

@@ -0,0 +1,53 @@
// 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();

View File

@@ -0,0 +1,25 @@
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();

View File

@@ -0,0 +1,129 @@
<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>

View File

@@ -0,0 +1,55 @@
<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>

View File

@@ -1,16 +0,0 @@
import type Database from 'better-sqlite3';
import { normalizeDomain } from './repository';
export function isDomainAllowed(db: Database.Database, urlString: string): boolean {
let host: string;
try {
host = new URL(urlString).hostname;
} catch {
return false;
}
const normalized = normalizeDomain(host);
const row = db
.prepare('SELECT 1 AS ok FROM allowed_domain WHERE domain = ? LIMIT 1')
.get(normalized);
return row !== undefined;
}

View File

@@ -4,10 +4,8 @@ import { ImporterError } from './recipes/importer';
export function mapImporterError(e: unknown): never { export function mapImporterError(e: unknown): never {
if (e instanceof ImporterError) { if (e instanceof ImporterError) {
const status = const status =
e.code === 'INVALID_URL' || e.code === 'DOMAIN_BLOCKED' e.code === 'INVALID_URL'
? e.code === 'DOMAIN_BLOCKED' ? 400
? 403
: 400
: e.code === 'NO_RECIPE_FOUND' : e.code === 'NO_RECIPE_FOUND'
? 422 ? 422
: 502; // FETCH_FAILED : 502; // FETCH_FAILED

View File

@@ -2,7 +2,6 @@ import type Database from 'better-sqlite3';
import type { Recipe } from '$lib/types'; import type { Recipe } from '$lib/types';
import { fetchText } from '../http'; import { fetchText } from '../http';
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe'; import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
import { isDomainAllowed } from '../domains/whitelist';
import { downloadImage } from '../images/image-downloader'; import { downloadImage } from '../images/image-downloader';
import { import {
getRecipeById, getRecipeById,
@@ -14,7 +13,6 @@ export class ImporterError extends Error {
constructor( constructor(
public readonly code: public readonly code:
| 'INVALID_URL' | 'INVALID_URL'
| 'DOMAIN_BLOCKED'
| 'FETCH_FAILED' | 'FETCH_FAILED'
| 'NO_RECIPE_FOUND', | 'NO_RECIPE_FOUND',
message: string message: string
@@ -32,11 +30,12 @@ function hostnameOrThrow(url: string): string {
} }
} }
export async function previewRecipe(db: Database.Database, url: string): Promise<Recipe> { // Manuelle URL-Importe sind absichtlich NICHT mehr auf die allowed_domain-
// Whitelist beschränkt — der User pastet bewusst eine URL und erwartet,
// dass der Import klappt. Die Whitelist bleibt für die Web-Suche (searxng)
// relevant, weil dort ein breites Crawl-Feld eingeschränkt werden soll.
export async function previewRecipe(_db: Database.Database, url: string): Promise<Recipe> {
const host = hostnameOrThrow(url); const host = hostnameOrThrow(url);
if (!isDomainAllowed(db, url)) {
throw new ImporterError('DOMAIN_BLOCKED', `Domain not allowed: ${host}`);
}
let html: string; let html: string;
try { try {
html = await fetchText(url); html = await fetchText(url);

View File

@@ -0,0 +1,42 @@
export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only';
export type RequestShape = { url: string; method: string };
// Pure function — sole decision-maker for "which strategy for this request?".
// Called by the service worker for every fetch event.
export function resolveStrategy(req: RequestShape): CacheStrategy {
// All write methods: never cache.
if (req.method !== 'GET' && req.method !== 'HEAD') return 'network-only';
// Reduce URL to pathname — query string not needed for matching
// except that online-only endpoints need no special handling here.
const path = req.url.startsWith('http') ? new URL(req.url).pathname : req.url.split('?')[0];
// Explicitly online-only GETs
if (
path === '/api/recipes/import' ||
path === '/api/recipes/preview' ||
path.startsWith('/api/recipes/search/web')
) {
return 'network-only';
}
// Images
if (path.startsWith('/images/')) return 'images';
// App-shell: build assets and known static files
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';
}
// Everything else: recipe pages, API reads, lists — all SWR.
return 'swr';
}

View File

@@ -0,0 +1,14 @@
// 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 };
}

View File

@@ -12,6 +12,11 @@
import SearchLoader from '$lib/components/SearchLoader.svelte'; import SearchLoader from '$lib/components/SearchLoader.svelte';
import SearchFilter from '$lib/components/SearchFilter.svelte'; import SearchFilter from '$lib/components/SearchFilter.svelte';
import UpdateToast from '$lib/components/UpdateToast.svelte'; import UpdateToast from '$lib/components/UpdateToast.svelte';
import Toast from '$lib/components/Toast.svelte';
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
import { network } from '$lib/client/network.svelte';
import { installPrompt } from '$lib/client/install-prompt.svelte';
import { registerServiceWorker } from '$lib/client/sw-register';
import type { SearchHit } from '$lib/server/recipes/search-local'; import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng'; import type { WebHit } from '$lib/server/search/searxng';
@@ -202,6 +207,9 @@
void wishlistStore.refresh(); void wishlistStore.refresh();
void searchFilterStore.load(); void searchFilterStore.load();
void pwaStore.init(); void pwaStore.init();
network.init();
installPrompt.init();
void registerServiceWorker();
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKey); document.addEventListener('keydown', handleKey);
return () => { return () => {
@@ -211,6 +219,8 @@
}); });
</script> </script>
<Toast />
<SyncIndicator />
<ConfirmDialog /> <ConfirmDialog />
<UpdateToast /> <UpdateToast />

View File

@@ -10,6 +10,7 @@
import SearchFilter from '$lib/components/SearchFilter.svelte'; import SearchFilter from '$lib/components/SearchFilter.svelte';
import { profileStore } from '$lib/client/profile.svelte'; import { profileStore } from '$lib/client/profile.svelte';
import { searchFilterStore } from '$lib/client/search-filter.svelte'; import { searchFilterStore } from '$lib/client/search-filter.svelte';
import { requireOnline } from '$lib/client/require-online';
const LOCAL_PAGE = 30; const LOCAL_PAGE = 30;
@@ -357,6 +358,7 @@
async function dismissFromRecent(recipeId: number, e: MouseEvent) { async function dismissFromRecent(recipeId: number, e: MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!requireOnline('Das Entfernen')) return;
recent = recent.filter((r) => r.id !== recipeId); recent = recent.filter((r) => r.id !== recipeId);
await fetch(`/api/recipes/${recipeId}`, { await fetch(`/api/recipes/${recipeId}`, {
method: 'PATCH', method: 'PATCH',

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { Globe, Users, DatabaseBackup, type Icon } from 'lucide-svelte'; import { Globe, Users, DatabaseBackup, Smartphone, type Icon } from 'lucide-svelte';
let { children } = $props(); let { children } = $props();
const items: { href: string; label: string; icon: typeof Icon }[] = [ const items: { href: string; label: string; icon: typeof Icon }[] = [
{ href: '/admin/domains', label: 'Domains', icon: Globe }, { href: '/admin/domains', label: 'Domains', icon: Globe },
{ href: '/admin/profiles', label: 'Profile', icon: Users }, { href: '/admin/profiles', label: 'Profile', icon: Users },
{ href: '/admin/backup', label: 'Backup', icon: DatabaseBackup } { href: '/admin/backup', label: 'Backup', icon: DatabaseBackup },
{ href: '/admin/app', label: 'App', icon: Smartphone }
]; ];
</script> </script>

View File

@@ -0,0 +1,142 @@
<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 } 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>

View File

@@ -3,6 +3,7 @@
import { Pencil, Check, X, Globe } from 'lucide-svelte'; import { Pencil, Check, X, Globe } from 'lucide-svelte';
import type { AllowedDomain } from '$lib/types'; import type { AllowedDomain } from '$lib/types';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
let domains = $state<AllowedDomain[]>([]); let domains = $state<AllowedDomain[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -25,6 +26,7 @@
async function add() { async function add() {
errored = null; errored = null;
if (!newDomain.trim()) return; if (!newDomain.trim()) return;
if (!requireOnline('Das Hinzufügen')) return;
adding = true; adding = true;
const res = await fetch('/api/domains', { const res = await fetch('/api/domains', {
method: 'POST', method: 'POST',
@@ -59,6 +61,7 @@
async function saveEdit(d: AllowedDomain) { async function saveEdit(d: AllowedDomain) {
if (!editDomain.trim()) return; if (!editDomain.trim()) return;
if (!requireOnline('Das Speichern')) return;
saving = true; saving = true;
try { try {
const res = await fetch(`/api/domains/${d.id}`, { const res = await fetch(`/api/domains/${d.id}`, {
@@ -92,6 +95,7 @@
destructive: true destructive: true
}); });
if (!ok) return; if (!ok) return;
if (!requireOnline('Das Entfernen')) return;
await fetch(`/api/domains/${d.id}`, { method: 'DELETE' }); await fetch(`/api/domains/${d.id}`, { method: 'DELETE' });
await load(); await load();
} }

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { profileStore } from '$lib/client/profile.svelte'; import { profileStore } from '$lib/client/profile.svelte';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
let newName = $state(''); let newName = $state('');
let newEmoji = $state('🍳'); let newEmoji = $state('🍳');
@@ -10,6 +11,7 @@
async function add() { async function add() {
errored = null; errored = null;
if (!newName.trim()) return; if (!newName.trim()) return;
if (!requireOnline('Das Anlegen')) return;
adding = true; adding = true;
try { try {
await profileStore.create(newName.trim(), newEmoji || null); await profileStore.create(newName.trim(), newEmoji || null);
@@ -24,6 +26,7 @@
async function rename(id: number, currentName: string) { async function rename(id: number, currentName: string) {
const next = prompt('Neuer Name:', currentName); const next = prompt('Neuer Name:', currentName);
if (!next || next === currentName) return; if (!next || next === currentName) return;
if (!requireOnline('Das Umbenennen')) return;
const res = await fetch(`/api/profiles/${id}`, { const res = await fetch(`/api/profiles/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -49,6 +52,7 @@
destructive: true destructive: true
}); });
if (!ok) return; if (!ok) return;
if (!requireOnline('Das Löschen')) return;
await fetch(`/api/profiles/${id}`, { method: 'DELETE' }); await fetch(`/api/profiles/${id}`, { method: 'DELETE' });
if (profileStore.activeId === id) profileStore.clear(); if (profileStore.activeId === id) profileStore.clear();
await profileStore.load(); await profileStore.load();

View File

@@ -35,7 +35,7 @@ const PatchSchema = z
.object({ .object({
title: z.string().min(1).max(200).optional(), title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(), description: z.string().max(2000).nullable().optional(),
servings_default: z.number().int().positive().nullable().optional(), servings_default: z.number().int().nonnegative().nullable().optional(),
servings_unit: z.string().max(30).nullable().optional(), servings_unit: z.string().max(30).nullable().optional(),
prep_time_min: z.number().int().nonnegative().nullable().optional(), prep_time_min: z.number().int().nonnegative().nullable().optional(),
cook_time_min: z.number().int().nonnegative().nullable().optional(), cook_time_min: z.number().int().nonnegative().nullable().optional(),

View File

@@ -3,6 +3,7 @@
import { CookingPot, Link, Plus, ChevronDown, Pencil } from 'lucide-svelte'; import { CookingPot, Link, Plus, ChevronDown, Pencil } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { alertAction } from '$lib/client/confirm.svelte'; import { alertAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -35,12 +36,14 @@
e.preventDefault(); e.preventDefault();
const url = importUrl.trim(); const url = importUrl.trim();
if (!url) return; if (!url) return;
if (!requireOnline('Der URL-Import')) return;
importOpen = false; importOpen = false;
goto(`/preview?url=${encodeURIComponent(url)}`); goto(`/preview?url=${encodeURIComponent(url)}`);
} }
async function createBlank() { async function createBlank() {
if (creatingBlank) return; if (creatingBlank) return;
if (!requireOnline('Das Anlegen')) return;
menuOpen = false; menuOpen = false;
creatingBlank = true; creatingBlank = true;
try { try {

View File

@@ -20,6 +20,7 @@
import { profileStore } from '$lib/client/profile.svelte'; import { profileStore } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte'; import { wishlistStore } from '$lib/client/wishlist.svelte';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
import type { CommentRow } from '$lib/server/recipes/actions'; import type { CommentRow } from '$lib/server/recipes/actions';
let { data } = $props(); let { data } = $props();
@@ -41,6 +42,24 @@
let saving = $state(false); let saving = $state(false);
let recipeState = $state(data.recipe); let recipeState = $state(data.recipe);
// Einmalige Pulse-Animation beim Aktivieren (nicht beim Wieder-Abwählen).
// Per tick()-Zwischenschritt "aus → an" erzwingen, damit die Animation
// auch bei mehrmaligem Klick innerhalb weniger hundert ms neu startet.
let pulseFav = $state(false);
let pulseWish = $state(false);
async function firePulse(which: 'fav' | 'wish') {
if (which === 'fav') {
pulseFav = false;
await tick();
pulseFav = true;
} else {
pulseWish = false;
await tick();
pulseWish = true;
}
}
async function saveRecipe(patch: { async function saveRecipe(patch: {
title: string; title: string;
description: string | null; description: string | null;
@@ -51,6 +70,7 @@
ingredients: typeof data.recipe.ingredients; ingredients: typeof data.recipe.ingredients;
steps: typeof data.recipe.steps; steps: typeof data.recipe.steps;
}) { }) {
if (!requireOnline('Das Speichern')) return;
saving = true; saving = true;
try { try {
const res = await fetch(`/api/recipes/${data.recipe.id}`, { const res = await fetch(`/api/recipes/${data.recipe.id}`, {
@@ -109,6 +129,7 @@
}); });
return; return;
} }
if (!requireOnline('Das Rating')) return;
await fetch(`/api/recipes/${data.recipe.id}/rating`, { await fetch(`/api/recipes/${data.recipe.id}/rating`, {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -127,16 +148,19 @@
}); });
return; return;
} }
if (!requireOnline('Das Favorit-Setzen')) return;
const profileId = profileStore.active.id; const profileId = profileStore.active.id;
const method = isFav ? 'DELETE' : 'PUT'; const wasFav = isFav;
const method = wasFav ? 'DELETE' : 'PUT';
await fetch(`/api/recipes/${data.recipe.id}/favorite`, { await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
method, method,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileId }) body: JSON.stringify({ profile_id: profileId })
}); });
favoriteProfileIds = isFav favoriteProfileIds = wasFav
? favoriteProfileIds.filter((id) => id !== profileId) ? favoriteProfileIds.filter((id) => id !== profileId)
: [...favoriteProfileIds, profileId]; : [...favoriteProfileIds, profileId];
if (!wasFav) void firePulse('fav');
} }
async function logCooked() { async function logCooked() {
@@ -147,6 +171,7 @@
}); });
return; return;
} }
if (!requireOnline('Der Kochjournal-Eintrag')) return;
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, { const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -168,6 +193,7 @@
}); });
return; return;
} }
if (!requireOnline('Das Speichern des Kommentars')) return;
const text = newComment.trim(); const text = newComment.trim();
if (!text) return; if (!text) return;
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, { const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
@@ -199,6 +225,7 @@
destructive: true destructive: true
}); });
if (!ok) return; if (!ok) return;
if (!requireOnline('Das Löschen')) return;
await fetch(`/api/recipes/${data.recipe.id}`, { method: 'DELETE' }); await fetch(`/api/recipes/${data.recipe.id}`, { method: 'DELETE' });
goto('/'); goto('/');
} }
@@ -222,6 +249,7 @@
editingTitle = false; editingTitle = false;
return; return;
} }
if (!requireOnline('Das Umbenennen')) return;
const res = await fetch(`/api/recipes/${data.recipe.id}`, { const res = await fetch(`/api/recipes/${data.recipe.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -257,8 +285,10 @@
}); });
return; return;
} }
if (!requireOnline('Das Wunschlisten-Setzen')) return;
const profileId = profileStore.active.id; const profileId = profileStore.active.id;
if (onMyWishlist) { const wasOn = onMyWishlist;
if (wasOn) {
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, { await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, {
method: 'DELETE' method: 'DELETE'
}); });
@@ -272,6 +302,7 @@
wishlistProfileIds = [...wishlistProfileIds, profileId]; wishlistProfileIds = [...wishlistProfileIds, profileId];
} }
void wishlistStore.refresh(); void wishlistStore.refresh();
if (!wasOn) void firePulse('wish');
} }
// Wake-Lock — Bildschirm beim Kochen nicht dimmen lassen. // Wake-Lock — Bildschirm beim Kochen nicht dimmen lassen.
@@ -387,11 +418,23 @@
{/if} {/if}
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn" class:heart={isFav} onclick={toggleFavorite}> <button
class="btn"
class:heart={isFav}
class:pulse={pulseFav}
onclick={toggleFavorite}
onanimationend={() => (pulseFav = false)}
>
<Heart size={18} strokeWidth={2} fill={isFav ? 'currentColor' : 'none'} /> <Heart size={18} strokeWidth={2} fill={isFav ? 'currentColor' : 'none'} />
<span>Favorit</span> <span>Favorit</span>
</button> </button>
<button class="btn" class:wish={onMyWishlist} onclick={toggleWishlist}> <button
class="btn"
class:wish={onMyWishlist}
class:pulse={pulseWish}
onclick={toggleWishlist}
onanimationend={() => (pulseWish = false)}
>
{#if onMyWishlist} {#if onMyWishlist}
<Check size={18} strokeWidth={2.5} /> <Check size={18} strokeWidth={2.5} />
<span>Auf Wunschliste</span> <span>Auf Wunschliste</span>
@@ -590,11 +633,38 @@
color: #c53030; color: #c53030;
border-color: #f1b4b4; border-color: #f1b4b4;
background: #fdf3f3; background: #fdf3f3;
--pulse-color: rgba(197, 48, 48, 0.45);
} }
.btn.wish { .btn.wish {
color: #2b6a3d; color: #2b6a3d;
border-color: #b7d6c2; border-color: #b7d6c2;
background: #eaf4ed; background: #eaf4ed;
--pulse-color: rgba(43, 106, 61, 0.45);
}
/* Einmalige Bestätigung beim Aktivieren der Aktion — kurzer Scale-Bounce
plus ausklingender Ring in der Aktionsfarbe (siehe --pulse-color).
prefers-reduced-motion: Ring aus, kein Scale. */
.btn.pulse {
animation: btnPulse 0.5s ease-out;
}
@keyframes btnPulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 var(--pulse-color, rgba(43, 106, 61, 0.45));
}
55% {
transform: scale(1.07);
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.btn.pulse {
animation: none;
}
} }
.btn.screen-on { .btn.screen-on {
color: #b07e00; color: #b07e00;

View File

@@ -1,11 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Heart, Trash2, CookingPot } from 'lucide-svelte'; import { Utensils, Trash2, CookingPot } from 'lucide-svelte';
import { profileStore } from '$lib/client/profile.svelte'; import { profileStore } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte'; import { wishlistStore } from '$lib/client/wishlist.svelte';
import { alertAction, confirmAction } from '$lib/client/confirm.svelte'; import { alertAction, confirmAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository'; import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
{ value: 'popular', label: 'Meist gewünscht' },
{ value: 'newest', label: 'Neueste' },
{ value: 'oldest', label: 'Älteste' }
];
let entries = $state<WishlistEntry[]>([]); let entries = $state<WishlistEntry[]>([]);
let loading = $state(true); let loading = $state(true);
let sort = $state<SortKey>('popular'); let sort = $state<SortKey>('popular');
@@ -35,6 +42,7 @@
}); });
return; return;
} }
if (!requireOnline('Die Wunschlisten-Aktion')) return;
const profileId = profileStore.active.id; const profileId = profileStore.active.id;
if (entry.on_my_wishlist) { if (entry.on_my_wishlist) {
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, { await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
@@ -59,6 +67,7 @@
destructive: true destructive: true
}); });
if (!ok) return; if (!ok) return;
if (!requireOnline('Das Entfernen')) return;
await fetch(`/api/wishlist/${entry.recipe_id}?all=true`, { method: 'DELETE' }); await fetch(`/api/wishlist/${entry.recipe_id}?all=true`, { method: 'DELETE' });
await load(); await load();
void wishlistStore.refresh(); void wishlistStore.refresh();
@@ -80,15 +89,19 @@
<p class="sub">Das wollen wir bald mal essen.</p> <p class="sub">Das wollen wir bald mal essen.</p>
</header> </header>
<div class="controls"> <div class="sort-chips" role="tablist" aria-label="Sortierung">
<label> {#each SORT_OPTIONS as s (s.value)}
Sortieren: <button
<select bind:value={sort}> type="button"
<option value="popular">Am meisten gewünscht</option> role="tab"
<option value="newest">Neueste zuerst</option> aria-selected={sort === s.value}
<option value="oldest">Älteste zuerst</option> class="chip"
</select> class:active={sort === s.value}
</label> onclick={() => (sort = s.value)}
>
{s.label}
</button>
{/each}
</div> </div>
{#if loading} {#if loading}
@@ -131,7 +144,7 @@
aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'} aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
onclick={() => toggleMine(e)} onclick={() => toggleMine(e)}
> >
<Heart size={18} strokeWidth={2} fill={e.on_my_wishlist ? 'currentColor' : 'none'} /> <Utensils size={18} strokeWidth={2} />
{#if e.wanted_by_count > 0} {#if e.wanted_by_count > 0}
<span class="count">{e.wanted_by_count}</span> <span class="count">{e.wanted_by_count}</span>
{/if} {/if}
@@ -162,24 +175,32 @@
margin: 0.2rem 0 0; margin: 0.2rem 0 0;
color: #666; color: #666;
} }
.controls { .sort-chips {
display: flex; display: flex;
justify-content: flex-end; flex-wrap: wrap;
padding: 0.5rem 0 1rem; gap: 0.35rem;
margin: 0.5rem 0 1rem;
} }
.controls label { .chip {
display: inline-flex; padding: 0.4rem 0.85rem;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
color: #555;
}
.controls select {
padding: 0.5rem 0.75rem;
border: 1px solid #cfd9d1;
border-radius: 10px;
min-height: 40px;
background: white; background: white;
border: 1px solid #cfd9d1;
border-radius: 999px;
color: #2b6a3d;
font-size: 0.88rem;
cursor: pointer;
min-height: 36px;
font-family: inherit;
white-space: nowrap;
}
.chip:hover {
background: #f4f8f5;
}
.chip.active {
background: #2b6a3d;
color: white;
border-color: #2b6a3d;
font-weight: 600;
} }
.muted { .muted {
color: #888; color: #888;
@@ -284,9 +305,9 @@
color: #444; color: #444;
} }
.like.active { .like.active {
color: #c53030; color: #2b6a3d;
background: #fdf3f3; background: #eaf4ed;
border-color: #f1b4b4; border-color: #b7d6c2;
} }
.del:hover { .del:hover {
color: #c53030; color: #c53030;

View File

@@ -2,88 +2,258 @@
/// <reference no-default-lib="true"/> /// <reference no-default-lib="true"/>
/// <reference lib="esnext" /> /// <reference lib="esnext" />
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { build, files, version } from '$service-worker'; import { build, files, version } from '$service-worker';
import { resolveStrategy } from '$lib/sw/cache-strategy';
import { diffManifest } from '$lib/sw/diff-manifest';
const sw = self as unknown as ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
const APP_CACHE = `kochwas-app-${version}`; const SHELL_CACHE = `kochwas-shell-${version}`;
const IMAGE_CACHE = `kochwas-images-v1`; const DATA_CACHE = 'kochwas-data-v1';
const APP_ASSETS = [...build, ...files]; const IMAGES_CACHE = 'kochwas-images-v1';
sw.addEventListener('install', (event) => { // App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt)
event.waitUntil( const SHELL_ASSETS = [...build, ...files];
caches.open(APP_CACHE).then((cache) => cache.addAll(APP_ASSETS))
);
// Activate new worker without waiting for old clients to close.
void sw.skipWaiting();
});
sw.addEventListener('activate', (event) => { self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
(async () => { (async () => {
const cache = await caches.open(SHELL_CACHE);
await cache.addAll(SHELL_ASSETS);
// Kein self.skipWaiting() hier — der Client (pwaStore) fragt den
// User via UpdateToast, ob der neue SW sofort übernehmen soll, und
// schickt dann eine SKIP_WAITING-Message. Ohne diese Trennung
// würde pwaStore beim Install-Event fälschlich "Neue Version"
// zeigen (weil statechange='installed' + controller=alter SW), und
// der neue SW würde einen Tick später ungefragt übernehmen.
})()
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Alte Shell-Caches (vorherige Versionen) räumen
const keys = await caches.keys(); const keys = await caches.keys();
await Promise.all( await Promise.all(
keys keys
.filter((k) => k.startsWith('kochwas-app-') && k !== APP_CACHE) .filter((k) => k.startsWith('kochwas-shell-') && k !== SHELL_CACHE)
.map((k) => caches.delete(k)) .map((k) => caches.delete(k))
); );
await sw.clients.claim(); await self.clients.claim();
})() })()
); );
}); });
sw.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const req = event.request; const req = event.request;
if (req.method !== 'GET') return; if (new URL(req.url).origin !== self.location.origin) return; // Cross-Origin unangetastet
const url = new URL(req.url); const strategy = resolveStrategy({ url: req.url, method: req.method });
if (url.origin !== location.origin) return; if (strategy === 'network-only') return;
// Images served from /images/* — cache-first with background update if (strategy === 'shell') {
if (url.pathname.startsWith('/images/')) { event.respondWith(cacheFirst(req, SHELL_CACHE));
event.respondWith( } else if (strategy === 'images') {
(async () => { event.respondWith(cacheFirst(req, IMAGES_CACHE));
const cache = await caches.open(IMAGE_CACHE); } else if (strategy === 'swr') {
const cached = await cache.match(req); event.respondWith(staleWhileRevalidate(req, DATA_CACHE));
const network = fetch(req) }
});
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) => { .then((res) => {
if (res.ok) void cache.put(req, res.clone()); if (res.ok) cache.put(req, res.clone()).catch(() => {});
return res; return res;
}) })
.catch(() => undefined); .catch(() => hit ?? Response.error());
return cached ?? (await network) ?? new Response('Offline', { status: 503 }); return hit ?? fetchPromise;
})()
);
return;
} }
// App shell assets (build/* and static files) — cache-first const META_CACHE = 'kochwas-meta';
if (APP_ASSETS.includes(url.pathname)) { const MANIFEST_KEY = '/__cache-manifest__';
event.respondWith( const PAGE_SIZE = 50; // /api/recipes/all limitiert auf 50
(async () => { const CONCURRENCY = 4;
const cache = await caches.open(APP_CACHE);
const cached = await cache.match(req);
return cached ?? fetch(req);
})()
);
return;
}
// API and HTML pages — network-first, fall back to cache for HTML type RecipeSummary = { id: number; image_path: string | null };
if (req.destination === 'document') {
event.respondWith( self.addEventListener('message', (event) => {
(async () => { const data = event.data as { type?: string } | undefined;
try { if (!data) return;
const res = await fetch(req); if (data.type === 'sync-start') {
const cache = await caches.open(APP_CACHE); event.waitUntil(runSync(false));
if (res.ok) void cache.put(req, res.clone()); } else if (data.type === 'sync-check') {
return res; event.waitUntil(runSync(true));
} catch { } else if (data.type === 'SKIP_WAITING') {
const cached = await caches.match(req); // Wird vom pwaStore nach User-Klick auf "Neu laden" geschickt.
return cached ?? new Response('Offline', { status: 503 }); void self.skipWaiting();
} } else if (data.type === 'GET_VERSION') {
})() // Zombie-Schutz: Chromium hält nach einem SKIP_WAITING-Zyklus
); // mitunter einen bit-identischen waiting-SW im Registration-Slot
// (Race zwischen SW-Update-Check während activate). Ohne diesen
// Version-Handshake zeigt init() den „Neue Version"-Toast bei jedem
// Reload erneut, obwohl es nichts zu aktualisieren gibt.
const port = event.ports[0] as MessagePort | undefined;
port?.postMessage({ version });
} }
}); });
async function runSync(isUpdate: boolean): Promise<void> {
try {
// Storage-Quota-Check vor dem Pre-Cache
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;
}
}
const summaries = await fetchAllSummaries();
const currentIds = summaries.map((s) => s.id);
const cachedIds = await loadCachedIds();
const { toAdd, toRemove } = diffManifest(currentIds, cachedIds);
const worklist = isUpdate ? toAdd : currentIds; // initial: alles laden
await broadcast({ type: 'sync-start', total: worklist.length });
const successful = new Set<number>();
let done = 0;
const tasks = worklist.map((id) => async () => {
const summary = summaries.find((s) => s.id === id);
const ok = await cacheRecipe(id, summary?.image_path ?? null);
if (ok) successful.add(id);
done += 1;
await broadcast({ type: 'sync-progress', current: done, total: worklist.length });
});
await runPool(tasks, CONCURRENCY);
if (isUpdate && toRemove.length > 0) {
await removeRecipes(toRemove);
}
// Manifest: für Update = (cached - toRemove) + neue successes
// Für Initial = nur die diesmal erfolgreich gecachten
const finalManifest = isUpdate
? Array.from(
new Set([...cachedIds.filter((id) => !toRemove.includes(id)), ...successful])
)
: Array.from(successful);
await saveCachedIds(finalManifest);
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<boolean> {
const data = await caches.open(DATA_CACHE);
const images = await caches.open(IMAGES_CACHE);
const [htmlOk, apiOk] = await Promise.all([
addToCache(data, `/recipes/${id}`),
addToCache(data, `/api/recipes/${id}`)
]);
if (imagePath && !/^https?:\/\//i.test(imagePath)) {
// Image-Fehler soll den Recipe-Eintrag nicht invalidieren (bei
// manchen Rezepten gibt es schlicht kein Bild)
await addToCache(images, `/images/${imagePath}`);
}
return htmlOk && apiOk;
}
async function addToCache(cache: Cache, url: string): Promise<boolean> {
try {
const res = await fetch(url);
if (!res.ok) {
console.warn(`[sw] cache miss ${url}: HTTP ${res.status}`);
return false;
}
await cache.put(url, res);
return true;
} catch (e) {
console.warn(`[sw] cache error ${url}:`, e);
return false;
}
}
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.
}
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' } })
);
}
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);
}
export {};

BIN
static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -13,7 +13,19 @@
"src": "/icon.svg", "src": "/icon.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any" "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"
} }
] ]
} }

122
tests/e2e/offline.spec.ts Normal file
View File

@@ -0,0 +1,122 @@
import { test as base, expect, request as apiRequest, type Page } from '@playwright/test';
// Seed-Fixture: die Tests brauchen mindestens ein Rezept in der DB,
// sonst gibt es nichts zu cachen/navigieren. Beim ersten Worker-Run
// schauen wir in /api/recipes/all nach — wenn leer, legen wir ein
// leeres Rezept per /api/recipes/blank an.
//
// Außerdem stellen wir sicher, dass ein Profil existiert (nötig für
// den Favorit-Button-Test). Das Profil-ID wird als Fixture-Wert
// weitergegeben, damit die Tests es in localStorage setzen können.
const test = base.extend<{ profileId: number }, { seeded: void; workerProfileId: number }>({
seeded: [
async ({}, use) => {
const ctx = await apiRequest.newContext({ baseURL: 'http://localhost:4173' });
try {
const res = await ctx.get('/api/recipes/all?sort=name&limit=1&offset=0');
const body = await res.json();
if (body.hits.length === 0) {
await ctx.post('/api/recipes/blank');
}
} finally {
await ctx.dispose();
}
await use();
},
{ scope: 'worker', auto: true }
],
workerProfileId: [
async ({}, use) => {
const ctx = await apiRequest.newContext({ baseURL: 'http://localhost:4173' });
let id: number;
try {
const listRes = await ctx.get('/api/profiles');
const profiles = await listRes.json();
if (profiles.length > 0) {
id = profiles[0].id;
} else {
const createRes = await ctx.post('/api/profiles', {
data: { name: 'Test', avatar_emoji: null }
});
const p = await createRes.json();
id = p.id;
}
} finally {
await ctx.dispose();
}
await use(id);
},
{ scope: 'worker', auto: false }
],
// Test-scoped Alias — wird von Tests direkt per Destrukturierung genutzt
profileId: async ({ workerProfileId }, use) => {
await use(workerProfileId);
}
});
// Wartet, bis der Service Worker aktiv ist und der initiale Sync
// wahrscheinlich durchgelaufen ist. Wir pollen den Status.
async function waitForSync(page: Page) {
await page.waitForFunction(
async () => {
const r = await navigator.serviceWorker.ready;
return !!r.active;
},
null,
{ timeout: 10_000 }
);
// Heuristik: 3 s reichen für den Pre-Cache eines einzelnen Seed-Rezepts.
// Falls flaky, auf 5000 erhöhen oder .pill.syncing wegwarten.
await page.waitForTimeout(3000);
}
test('offline navigation zeigt Rezept-Detail aus dem Cache', async ({ page, context }) => {
await page.goto('/');
await waitForSync(page);
// Einen existierenden Rezept-Link finden — Seed-Fixture garantiert mindestens einen.
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, profileId }) => {
// Profil-ID vor dem ersten Navigieren setzen, damit profileStore.load()
// das Profil aus localStorage liest und active != null ist.
await page.addInitScript((id: number) => {
localStorage.setItem('kochwas.activeProfileId', String(id));
}, profileId);
await page.goto('/');
await waitForSync(page);
// Rezept-Detail-Seite vorab besuchen, damit der SW sie cacht.
await page.goto('/recipes');
const firstLink = page.locator('a[href^="/recipes/"]').first();
const href = await firstLink.getAttribute('href');
await page.goto(href!);
// Kurz warten damit die Detail-Seite im SW-Cache landet.
await page.waitForTimeout(500);
await context.setOffline(true);
// Neu navigieren zur gecachten Detail-Seite — SW liefert aus dem Cache.
await page.goto(href!, { waitUntil: 'commit' });
await expect(page.locator('h1')).toBeVisible();
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);
// Kein Reload nötig: network.svelte.ts lauscht auf den 'offline'-Browser-
// Event, der sofort feuert wenn context.setOffline(true) gesetzt wird.
await context.setOffline(true);
await expect(page.locator('.wrap .pill.offline')).toContainText('Offline');
});

6
tests/e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,6 @@
import { test, expect } from '@playwright/test';
test('home loads', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Kochwas');
});

View File

@@ -7,7 +7,6 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { openInMemoryForTest } from '../../src/lib/server/db'; import { openInMemoryForTest } from '../../src/lib/server/db';
import { addDomain } from '../../src/lib/server/domains/repository';
import { importRecipe, previewRecipe, ImporterError } from '../../src/lib/server/recipes/importer'; import { importRecipe, previewRecipe, ImporterError } from '../../src/lib/server/recipes/importer';
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
@@ -61,17 +60,9 @@ afterEach(async () => {
}); });
describe('previewRecipe', () => { describe('previewRecipe', () => {
it('throws DOMAIN_BLOCKED if host not whitelisted', async () => { it('accepts any domain — manuelle URL-Importe sind nicht auf die Whitelist beschränkt', async () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
// note: no domain added // keine Domain in der Whitelist — preview muss trotzdem klappen
await expect(previewRecipe(db, `${baseUrl}/recipe`)).rejects.toMatchObject({
code: 'DOMAIN_BLOCKED'
});
});
it('returns parsed recipe for whitelisted domain', async () => {
const db = openInMemoryForTest();
addDomain(db, '127.0.0.1');
const r = await previewRecipe(db, `${baseUrl}/recipe`); const r = await previewRecipe(db, `${baseUrl}/recipe`);
expect(r.title.toLowerCase()).toContain('schupfnudel'); expect(r.title.toLowerCase()).toContain('schupfnudel');
expect(r.source_url).toBe(`${baseUrl}/recipe`); expect(r.source_url).toBe(`${baseUrl}/recipe`);
@@ -80,17 +71,22 @@ describe('previewRecipe', () => {
it('throws NO_RECIPE_FOUND when HTML has no Recipe JSON-LD', async () => { it('throws NO_RECIPE_FOUND when HTML has no Recipe JSON-LD', async () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
addDomain(db, '127.0.0.1');
await expect(previewRecipe(db, `${baseUrl}/bare`)).rejects.toMatchObject({ await expect(previewRecipe(db, `${baseUrl}/bare`)).rejects.toMatchObject({
code: 'NO_RECIPE_FOUND' code: 'NO_RECIPE_FOUND'
}); });
}); });
it('throws INVALID_URL for malformed input', async () => {
const db = openInMemoryForTest();
await expect(previewRecipe(db, 'not a url')).rejects.toMatchObject({
code: 'INVALID_URL'
});
});
}); });
describe('importRecipe', () => { describe('importRecipe', () => {
it('imports, persists, and is idempotent', async () => { it('imports, persists, and is idempotent', async () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
addDomain(db, '127.0.0.1');
const first = await importRecipe(db, imgDir, `${baseUrl}/recipe`); const first = await importRecipe(db, imgDir, `${baseUrl}/recipe`);
expect(first.duplicate).toBe(false); expect(first.duplicate).toBe(false);
expect(first.id).toBeGreaterThan(0); expect(first.id).toBeGreaterThan(0);
@@ -104,9 +100,9 @@ describe('importRecipe', () => {
expect(second.id).toBe(first.id); expect(second.id).toBe(first.id);
}); });
it('surfaces ImporterError type', async () => { it('surfaces ImporterError type when no recipe on page', async () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
await expect(importRecipe(db, imgDir, `${baseUrl}/recipe`)).rejects.toBeInstanceOf( await expect(importRecipe(db, imgDir, `${baseUrl}/bare`)).rejects.toBeInstanceOf(
ImporterError ImporterError
); );
}); });

View File

@@ -8,7 +8,6 @@ import {
updateDomain, updateDomain,
getDomainById getDomainById
} from '../../src/lib/server/domains/repository'; } from '../../src/lib/server/domains/repository';
import { isDomainAllowed } from '../../src/lib/server/domains/whitelist';
describe('allowed domains', () => { describe('allowed domains', () => {
it('round-trips domains', () => { it('round-trips domains', () => {
@@ -19,18 +18,10 @@ describe('allowed domains', () => {
expect(all.map((d) => d.domain).sort()).toEqual(['chefkoch.de', 'emmikochteinfach.de']); expect(all.map((d) => d.domain).sort()).toEqual(['chefkoch.de', 'emmikochteinfach.de']);
}); });
it('normalizes www. and case', () => { it('normalizes www. and case via addDomain', () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
addDomain(db, 'WWW.Chefkoch.DE'); addDomain(db, 'WWW.Chefkoch.DE');
expect(isDomainAllowed(db, 'https://chefkoch.de/abc')).toBe(true); expect(listDomains(db)[0].domain).toBe('chefkoch.de');
expect(isDomainAllowed(db, 'https://www.chefkoch.de/abc')).toBe(true);
expect(isDomainAllowed(db, 'https://fake.de/abc')).toBe(false);
});
it('rejects invalid urls', () => {
const db = openInMemoryForTest();
addDomain(db, 'chefkoch.de');
expect(isDomainAllowed(db, 'not a url')).toBe(false);
}); });
it('removes domains', () => { it('removes domains', () => {

View File

@@ -0,0 +1,41 @@
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');
});
});

View File

@@ -0,0 +1,34 @@
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([]);
});
});

View File

@@ -0,0 +1,23 @@
// @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);
});
});

View File

@@ -0,0 +1,176 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
class FakeSW extends EventTarget {
scriptURL = '/service-worker.js';
state: 'installed' | 'activated' = 'activated';
version: string | null;
postMessage = vi.fn((msg: unknown, transfer?: Transferable[]) => {
if ((msg as { type?: string } | null)?.type === 'GET_VERSION') {
const port = transfer?.[0] as MessagePort | undefined;
if (port && this.version !== null) port.postMessage({ version: this.version });
}
});
constructor(version: string | null = null) {
super();
this.version = version;
}
}
type Reg = {
active: FakeSW | null;
waiting: FakeSW | null;
installing: FakeSW | null;
addEventListener: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
function mountFakeSW(init: Partial<Reg>): {
registration: Reg;
fireControllerChange: () => void;
} {
const registration: Reg = {
active: init.active ?? null,
waiting: init.waiting ?? null,
installing: init.installing ?? null,
addEventListener: vi.fn(),
update: vi.fn().mockResolvedValue(undefined)
};
type Entry = { fn: (e: Event) => void; once: boolean };
const listeners: Entry[] = [];
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
ready: Promise.resolve(registration),
controller: registration.active,
addEventListener: vi.fn(
(type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions | boolean) => {
if (type !== 'controllerchange') return;
const once = typeof opts === 'object' ? !!opts.once : false;
listeners.push({ fn, once });
}
)
}
});
const fireControllerChange = () => {
const snap = [...listeners];
for (const e of snap) {
e.fn(new Event('controllerchange'));
if (e.once) {
const i = listeners.indexOf(e);
if (i >= 0) listeners.splice(i, 1);
}
}
};
return { registration, fireControllerChange };
}
async function flush(ms = 20): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
function stubLocationReload(): ReturnType<typeof vi.fn> {
const reload = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, reload }
});
return reload;
}
describe('pwa store', () => {
beforeEach(() => {
vi.resetModules();
});
it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING, KEIN Reload', async () => {
const active = new FakeSW('1776527907402');
const waiting = new FakeSW('1776527907402');
waiting.state = 'installed';
const { fireControllerChange } = mountFakeSW({ active, waiting });
const reload = stubLocationReload();
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
await pwaStore.init();
await flush();
expect(pwaStore.updateAvailable).toBe(false);
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
// Kritisch: selbst wenn der Browser nach dem silent SKIP_WAITING
// controllerchange feuert, darf kein Auto-Reload passieren — sonst
// Endlos-Loop, weil der nächste Seitenaufruf erneut einen Zombie
// bekommt.
fireControllerChange();
expect(reload).not.toHaveBeenCalled();
});
it('echtes Update (unterschiedliche Version): Toast', async () => {
const active = new FakeSW('1776526292782');
const waiting = new FakeSW('1776527907402');
waiting.state = 'installed';
mountFakeSW({ active, waiting });
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
await pwaStore.init();
await flush();
expect(pwaStore.updateAvailable).toBe(true);
expect(waiting.postMessage).not.toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
});
it('alter active-SW ohne GET_VERSION (Fallback): Toast', async () => {
const active = new FakeSW(null);
const waiting = new FakeSW('1776527907402');
waiting.state = 'installed';
mountFakeSW({ active, waiting });
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
await pwaStore.init();
await flush();
expect(pwaStore.updateAvailable).toBe(true);
});
it('kein waiting-SW: kein Toast', async () => {
mountFakeSW({ active: new FakeSW('1776527907402') });
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
await pwaStore.init();
await flush();
expect(pwaStore.updateAvailable).toBe(false);
});
it('reload() postet SKIP_WAITING, reload einmalig bei controllerchange', async () => {
const active = new FakeSW('v1');
const waiting = new FakeSW('v2');
waiting.state = 'installed';
const { fireControllerChange } = mountFakeSW({ active, waiting });
const reload = stubLocationReload();
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
await pwaStore.init();
await flush();
pwaStore.reload();
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
expect(reload).not.toHaveBeenCalled();
fireControllerChange();
expect(reload).toHaveBeenCalledTimes(1);
// Nochmal controllerchange → wegen { once: true } kein zweiter Reload.
fireControllerChange();
expect(reload).toHaveBeenCalledTimes(1);
});
it('reload() ohne waiting-SW ruft location.reload() sofort auf', async () => {
mountFakeSW({ active: new FakeSW('v1') });
const reload = stubLocationReload();
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
await pwaStore.init();
await flush();
pwaStore.reload();
expect(reload).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,30 @@
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' });
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('toast store', () => {
beforeEach(async () => {
vi.useFakeTimers();
const mod = await import('../../src/lib/client/toast.svelte');
mod.toastStore.toasts = [];
});
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);
});
});