33 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
hsiegeln
8e33b52f66 feat(quotes): 100 weitere Sprüche für die Startseite
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Der Quote-Pool war bei 51 — mit jedem Start sah man schnell
Wiederholungen. 100 weitere im gleichen augenzwinkernden Ton
hinzugefügt. Alle non-offensive, keiner doppelt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:50:10 +02:00
hsiegeln
60d0cd7659 feat(register): Rezept-hinzufügen-Dropdown mit URL-Import + Manuell
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Der bisherige immer sichtbare URL-Importbalken ist durch einen
"Rezept hinzufügen"-Button rechts im Register-Head ersetzt. Klick
öffnet ein kleines Dropdown mit zwei Optionen:

  • Von URL importieren — öffnet einen Modal-Dialog zur URL-Eingabe
    und leitet wie bisher nach /preview weiter.
  • Leeres Rezept — POST /api/recipes/blank, Weiterleitung nach
    /recipes/{id}?edit=1; die Detailseite erkennt den Param und
    startet direkt im Editor, entfernt ihn nach Aktivierung wieder
    aus der URL.

Der neue Blank-Endpoint legt ein Rezept mit Platzhalter-Titel
"Neues Rezept", Portions-Default 4 und leeren Listen an. Der User
füllt direkt im Edit-Modus aus und speichert wie gewohnt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:40:57 +02:00
hsiegeln
a10ebefb75 fix(favicons): HTML-<link rel=icon>-Parsing vor /favicon.ico
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Emmi kocht einfach (und viele andere WordPress-Seiten) liefert unter
/favicon.ico ein Hoster-Default — Zahnrad-Artige Grafik — während das
eigentliche Site-Icon nur per <link rel="icon"> im <head> auftaucht.
Jetzt ziehen wir die Icon-Kandidaten erst aus der Homepage, sortieren
nach "sweet spot" 32–192 px und fallen bei Fehlschlag wie bisher auf
/favicon.ico und dann Google s2/favicons zurück.

Migration 011 setzt alle favicon_path=NULL, damit existierende
(falsche) Favicons beim nächsten Start neu geladen werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:34:17 +02:00
hsiegeln
e56c1543d8 fix(home): Sort-Chip-Wechsel behält Scroll-Position
Beim Klick auf eine andere Sort-Pille wurde allRecipes=[] eager
gesetzt; dadurch kollabierte der Block unter den Chips und der
Browser snapte nach oben. Jetzt laden wir erst die neuen Treffer,
tauschen dann atomar. Sollte sich die Block-Höhe trotzdem ändern
(z.B. von 50 geladenen Items zurück auf 10), korrigieren wir per
scrollBy-Delta, damit die Chips visuell an Ort bleiben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:34:08 +02:00
hsiegeln
8c93099d91 fix(recipe): Header-Bild auf allen Viewports max 30vh
Die Mobile-Only-Regel führte auf Desktop zu riesigen 16:10-Covers
(auf 1440px-Breite fast 900px hoch). Jetzt gilt der 30vh-Deckel
überall — Bild bleibt proportional, Zutaten/Zubereitung bleiben
sichtbar ohne Scroll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:34:01 +02:00
53 changed files with 5543 additions and 244 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ data/
.env
.env.local
*.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. |
| **$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`. |
| **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
@@ -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/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/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)

View File

@@ -106,6 +106,35 @@ Bei Schema-Änderung:
- **Keine Svelte-Component-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird manuell getestet)
- **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)
- 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`
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:watch": "vitest",
"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": {
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.9.0",
"jsdom": "^29.0.2",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7",
"sharp": "^0.34.5",
"svelte": "^5.1.0",
"svelte-check": "^4.0.5",
"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 {
updateAvailable = $state(false);
private registration: ServiceWorkerRegistration | null = null;
@@ -5,6 +21,7 @@ class PwaStore {
async init(): Promise<void> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
try {
this.registration = await navigator.serviceWorker.ready;
} catch {
@@ -12,10 +29,8 @@ class PwaStore {
}
if (!this.registration) return;
// Wenn beim Mount schon ein neuer SW installiert und aktiv wartet,
// zeigen wir den Toast direkt an.
if (this.registration.waiting) {
this.updateAvailable = true;
if (this.registration.waiting && this.registration.active) {
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
}
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
@@ -31,17 +46,47 @@ class PwaStore {
const installing = this.registration?.installing;
if (!installing) return;
installing.addEventListener('statechange', () => {
// 'installed' UND ein laufender controller = Update für bestehenden Tab.
// (Ohne controller wäre das die erste Installation, kein Update.)
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
const active = this.registration?.active;
if (active && active !== installing) {
void this.evaluateWaiting(installing, active);
} else {
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 {
this.updateAvailable = false;
location.reload();
const waiting = this.registration?.waiting;
if (!waiting) {
// Kein wartender SW — reicht ein normaler 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 {
@@ -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();

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

@@ -175,16 +175,13 @@
display: block;
width: 100%;
aspect-ratio: 16 / 10;
/* Nie mehr als 30% der Bildschirmhöhe — auf schmalen Screens würde das
Bild sonst alles Wichtige wegdrücken, auf breiten Desktops wäre es
unverhältnismäßig groß. */
max-height: 30vh;
object-fit: cover;
background: #eef3ef;
}
/* Mobile: verhindere, dass das Header-Bild einen unverhältnismäßig
großen Teil des Viewports füllt und alles Wichtige wegdrückt. */
@media (max-width: 820px) {
.cover {
max-height: 30vh;
}
}
.hdr-body {
padding: 1rem 1rem 0.25rem;
}

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

@@ -48,7 +48,108 @@ export const QUOTES: readonly string[] = [
'Weil Essen eine Sprache ist, die jeder versteht.',
'Für die, die googeln, ob man Wasser anbrennen lassen kann.',
'Rezepte, die sogar dein WG-Mitbewohner nicht klaut. Okay, vielleicht doch.',
'Kochen. Essen. Wiederholen.'
'Kochen. Essen. Wiederholen.',
'Weil Nudeln-mit-Pesto kein Lebensmodell ist.',
'Rezepte, an die sich selbst die Pfanne erinnert.',
'Mehr Kochen, weniger Ratlosigkeit um 18 Uhr.',
'Endlich Abendessen ohne Hintergedanken.',
'Rezepte, die dein Gemüsefach endlich rechtfertigen.',
'Weil „Irgendwas mit Reis" keine Antwort ist.',
'Für alle, die das Salz bisher nur falsch dosiert haben.',
'Das kulinarische Äquivalent zu einer Umarmung.',
'Weil Kochen die einzige Show ist, bei der du Hauptrolle spielst.',
'Abendessen, das sich nicht entschuldigen muss.',
'Für Tage, an denen sogar Tiefkühlpizza aufgibt.',
'Rezepte, die deinen Rauchmelder schonen.',
'Weil „Da war doch noch was im Kühlschrank" kein Plan ist.',
'Kochen für Menschen, deren Fantasie im Supermarkt endet.',
'Für alle, die „Prise" schon mal gegoogelt haben.',
'Rezepte, die dein Sonntagabend-Ich dir danken wird.',
'Weil jede gute Küche mit einem „Ups" anfängt.',
'Für die, die ihren Kochlöffel lieber als die Kollegen mögen.',
'Kochen ist das neue Meditieren. Aber mit Geräuschen.',
'Rezepte, die halten, auch wenn du mal nicht.',
'Für alle, die „zart-schmelzend" als Lebensziel ansehen.',
'Abendessen mit Charakter. Manchmal auch Charakterkrise.',
'Weil Essen zubereiten billiger ist als Therapie-Stunden.',
'Rezepte für Menschen mit hohen Erwartungen und kleiner Pfanne.',
'Für Momente, in denen der Hunger größer ist als die Geduld.',
'Koch-Erinnerungen, ohne Oma anzurufen.',
'Weil nichts so verbindet wie ein geteilter Löffel.',
'Rezepte, bei denen der Käse nicht fragt, ob er darf.',
'Für alle, die „kurz ins Kochbuch schauen" für drei Stunden halten.',
'Essen, das dich nicht bei Instagram bloßstellt.',
'Rezepte ohne „Zuerst das Chaos sortieren"-Schritt.',
'Weil jedes gute Essen eine kleine Rebellion ist.',
'Für die, die Kochen als Sport zählen.',
'Abends kochen ist günstiger als Achtsamkeitskurse.',
'Rezepte, die dein Kaufhaus-Kochbuch alt aussehen lassen.',
'Für alle, die „Ich kann nicht kochen" als Feature, nicht Bug nutzen.',
'Weil Butter manchmal die Antwort ist. Und manchmal die Frage.',
'Hunger. Hinweise. Happy End.',
'Rezepte für Leute, die ihren Kaffee auch ernst nehmen.',
'Weil Kochen ein gutes Gespräch ersetzt. Manchmal.',
'Abendessen ohne Ausrede.',
'Rezepte, die der Küchenuhr einen Grund geben.',
'Für alle, die „Salz und Pfeffer nach Geschmack" als Lebensweisheit sehen.',
'Kochen gegen die Uhr, gewinnen gegen den Kühlschrank.',
'Rezepte, die sogar das Spülbecken beeindrucken.',
'Weil „Was gibt\'s?" eine Freundschaftsfrage ist.',
'Für Tage, an denen alles gelingt außer Google Maps.',
'Essen, das dich wieder zum Esser macht.',
'Rezepte, die in weniger Zeit klappen als ein Staffelfinale.',
'Weil dein Magen kein Demokrat ist.',
'Kochen ist, was passiert, während du andere Pläne machst.',
'Rezepte für die Küche, nicht für die Galerie.',
'Für alle, die beim Würzen Gefühle haben.',
'Weil jeder Topf mal sein Abenteuer braucht.',
'Rezepte, die auch bei Regen funktionieren.',
'Abendessen ohne Nachspielzeit.',
'Für die, die „Zutaten nach Augenmaß" als Lifestyle führen.',
'Kochen: die einzige App, die wirklich offline läuft.',
'Rezepte, die dein Besteck wieder in Bewegung bringen.',
'Für Menschen mit Küche, aber ohne Plan.',
'Weil Lorbeer kein Zufall ist.',
'Rezepte, die auch deine Nachbarn hören lassen.',
'Für alle, die beim Schnippeln Podcasts brauchen.',
'Abendessen, bei dem sich der Kühlschrank freut.',
'Rezepte, die deine Pfanne streicheln.',
'Weil Essen ohne Geschichte nur Kalorien ist.',
'Für Menschen, die ihre Kochschürze mit Stolz tragen.',
'Rezepte für den inneren Gourmet und den äußeren Alltag.',
'Weil jeder Abend einen guten Duft verdient hat.',
'Für alle, die Lieferheld auswendig können, aber nicht mehr wollen.',
'Kochen ist Sport für Menschen, die gerne sitzen.',
'Rezepte, bei denen dein Teller dich anlacht.',
'Für Tage, an denen nur Butter versteht.',
'Weil Pasta keine Jahreszeit kennt.',
'Rezepte, die dein „Kann nicht kochen"-Etikett abkratzen.',
'Abendessen für Optimisten und Realisten.',
'Für alle, die „kurz umrühren" als Kardio zählen.',
'Weil jede gute Mahlzeit mit „Kann ich helfen?" anfängt.',
'Rezepte, die dein Bauchgefühl bestätigen.',
'Für Küchen mit Charakter und Besitzer mit Hunger.',
'Kochen ist wie Atmen, nur mit Soße.',
'Rezepte, die keine Ausreden akzeptieren.',
'Für alle, die „al forno" schon fast richtig sprechen.',
'Weil Soße die Antwort auf fast jede Frage ist.',
'Abendessen ohne Drama. Außer beim Zwiebelschneiden.',
'Rezepte für den Herd und fürs Herz.',
'Für die, die „nur eine Kleinigkeit" mit drei Gängen übersetzen.',
'Weil Kochen der kürzeste Weg zu „Kannst du nochmal?" ist.',
'Rezepte, die auch dein Nachbar riechen darf.',
'Für alle, die Käse als Bindfaden der Freundschaft sehen.',
'Kochen schlägt Scrollen. Meistens.',
'Rezepte, die dein Küchentuch endlich rehabilitieren.',
'Für Menschen mit wenig Zeit und viel Hunger.',
'Weil Olivenöl zwar kein Grundnahrungsmittel ist, aber fast.',
'Rezepte, bei denen deine Waage nicht mitredet.',
'Abendessen ohne Kompromiss.',
'Für alle, die beim Kochen tanzen und beim Tanzen kochen.',
'Rezepte für den Alltag, die nicht nach Alltag schmecken.',
'Weil jede gute Mahlzeit einen Moment der Stille verdient.',
'Kochen: alte Tradition, neue Ergebnisse.',
'Weil „Ich hole nur Wasser" nie bei nur Wasser bleibt.'
];
export function randomQuote(): string {

View File

@@ -0,0 +1,8 @@
-- Der Favicon-Fetcher versucht ab jetzt zuerst die <link rel="icon">-Tags
-- aus der Homepage, weil WordPress-Seiten (z.B. Emmi kocht einfach) unter
-- /favicon.ico ein generisches Zahnrad-Default des Hosters ausliefern und
-- das eigentliche Site-Icon erst im <head> auftaucht. Einmalig alle
-- gespeicherten Favicon-Pfade zurücksetzen, damit sie mit der neuen
-- Heuristik neu geladen werden. Alte Dateien bleiben als Orphans im
-- IMAGE_DIR, sind aber harmlos.
UPDATE allowed_domain SET favicon_path = NULL;

View File

@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fetchBuffer } from '../http';
import { fetchBuffer, fetchText } from '../http';
import { listDomains, setDomainFavicon } from './repository';
const EXT_BY_CONTENT_TYPE: Record<string, string> = {
@@ -33,14 +33,83 @@ async function tryFetch(url: string): Promise<{ data: Uint8Array; contentType: s
}
}
// Parst <link rel="…icon">-Tags aus dem <head>. WordPress-Seiten liefern
// oft ein generisches /favicon.ico (Zahnrad-Default vom Hoster oder Plugin),
// während das eigentliche Site-Icon per <link rel="icon"> eingebunden ist.
// Darum zuerst den Head durchsehen, nicht blind /favicon.ico nehmen.
type IconLink = { href: string; size: number; isApple: boolean };
function extractIconLinks(html: string, baseUrl: string): IconLink[] {
const head = html.slice(0, 300_000);
const icons: IconLink[] = [];
const linkRe = /<link\b[^>]*>/gi;
for (const m of head.matchAll(linkRe)) {
const tag = m[0];
const relMatch = tag.match(/\brel\s*=\s*["']([^"']+)["']/i);
if (!relMatch) continue;
const rel = relMatch[1].toLowerCase();
const isApple = rel.includes('apple-touch-icon');
if (!isApple && !/\b(shortcut\s+icon|icon)\b/.test(rel)) continue;
const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i);
if (!hrefMatch) continue;
const raw = hrefMatch[1].trim();
if (!raw || raw.startsWith('data:')) continue;
let href: string;
try {
href = new URL(raw, baseUrl).toString();
} catch {
continue;
}
let size = 0;
const sizesMatch = tag.match(/\bsizes\s*=\s*["']([^"']+)["']/i);
if (sizesMatch) {
const sm = sizesMatch[1].match(/(\d+)\s*x\s*\d+/i);
if (sm) size = Number(sm[1]);
}
if (!size && isApple) size = 180;
icons.push({ href, size, isApple });
}
return icons;
}
// Holt Icon-Kandidaten per HTML-Parse. 32192 px bevorzugt (für 24×24-Darstellung
// ist das sharp genug, ohne SVG-Wahnsinn); alles außerhalb landet am Ende.
async function resolveIconsFromHtml(domain: string): Promise<string[]> {
try {
const baseUrl = `https://${domain}/`;
const html = await fetchText(baseUrl, {
timeoutMs: 3_500,
maxBytes: 256 * 1024,
allowTruncate: true
});
const icons = extractIconLinks(html, baseUrl);
if (icons.length === 0) return [];
const sweet = (s: number) => s >= 32 && s <= 192;
icons.sort((a, b) => {
if (sweet(a.size) && !sweet(b.size)) return -1;
if (!sweet(a.size) && sweet(b.size)) return 1;
return b.size - a.size;
});
return icons.map((i) => i.href);
} catch {
return [];
}
}
async function fetchFaviconBytes(
domain: string
): Promise<{ data: Uint8Array; contentType: string | null } | null> {
// 1. Versuche /favicon.ico direkt (klassisch, funktioniert auf fast allen Seiten).
// 1. Aus der Homepage die <link rel="icon">-Kandidaten ziehen — das
// ist normalerweise das "echte" Site-Icon, nicht der Hoster-Default.
const htmlIcons = await resolveIconsFromHtml(domain);
for (const url of htmlIcons) {
const got = await tryFetch(url);
if (got) return got;
}
// 2. Klassiker: /favicon.ico. Viele ältere Seiten haben nur den.
const direct = await tryFetch(`https://${domain}/favicon.ico`);
if (direct) return direct;
// 2. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas und
// geben SVG/PNG in der gewünschten Größe.
// 3. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas.
return tryFetch(`https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(domain)}`);
}

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 {
if (e instanceof ImporterError) {
const status =
e.code === 'INVALID_URL' || e.code === 'DOMAIN_BLOCKED'
? e.code === 'DOMAIN_BLOCKED'
? 403
: 400
e.code === 'INVALID_URL'
? 400
: e.code === 'NO_RECIPE_FOUND'
? 422
: 502; // FETCH_FAILED

View File

@@ -2,7 +2,6 @@ import type Database from 'better-sqlite3';
import type { Recipe } from '$lib/types';
import { fetchText } from '../http';
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
import { isDomainAllowed } from '../domains/whitelist';
import { downloadImage } from '../images/image-downloader';
import {
getRecipeById,
@@ -14,7 +13,6 @@ export class ImporterError extends Error {
constructor(
public readonly code:
| 'INVALID_URL'
| 'DOMAIN_BLOCKED'
| 'FETCH_FAILED'
| 'NO_RECIPE_FOUND',
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);
if (!isDomainAllowed(db, url)) {
throw new ImporterError('DOMAIN_BLOCKED', `Domain not allowed: ${host}`);
}
let html: string;
try {
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 SearchFilter from '$lib/components/SearchFilter.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 { WebHit } from '$lib/server/search/searxng';
@@ -202,6 +207,9 @@
void wishlistStore.refresh();
void searchFilterStore.load();
void pwaStore.init();
network.init();
installPrompt.init();
void registerServiceWorker();
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKey);
return () => {
@@ -211,6 +219,8 @@
});
</script>
<Toast />
<SyncIndicator />
<ConfirmDialog />
<UpdateToast />

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { CookingPot, X } from 'lucide-svelte';
import type { Snapshot } from './$types';
@@ -10,6 +10,7 @@
import SearchFilter from '$lib/components/SearchFilter.svelte';
import { profileStore } from '$lib/client/profile.svelte';
import { searchFilterStore } from '$lib/client/search-filter.svelte';
import { requireOnline } from '$lib/client/require-online';
const LOCAL_PAGE = 30;
@@ -43,6 +44,7 @@
let allExhausted = $state(false);
let allLoading = $state(false);
let allSentinel: HTMLElement | undefined = $state();
let allChips: HTMLElement | undefined = $state();
let allObserver: IntersectionObserver | null = null;
type SearchSnapshot = {
@@ -105,10 +107,34 @@
}
}
function resetAllRecipes() {
allRecipes = [];
allExhausted = false;
allLoading = false;
async function setAllSort(next: AllSort) {
if (next === allSort) return;
allSort = next;
if (typeof window !== 'undefined') localStorage.setItem('kochwas.allSort', next);
if (allLoading) return;
// Position der Sort-Chips vor dem Swap merken — wenn der Rezept-Block
// beim Tausch kürzer wird, hält der Browser sonst nicht Schritt und
// snapt nach oben. Wir korrigieren nach dem Render per scrollBy.
const chipsBefore = allChips?.getBoundingClientRect().top ?? 0;
allLoading = true;
try {
const res = await fetch(
`/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
);
if (!res.ok) return;
const body = await res.json();
const hits = body.hits as SearchHit[];
allRecipes = hits;
allExhausted = hits.length < ALL_PAGE;
await tick();
const chipsAfter = allChips?.getBoundingClientRect().top ?? 0;
const delta = chipsAfter - chipsBefore;
if (typeof window !== 'undefined' && Math.abs(delta) > 1) {
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
}
} finally {
allLoading = false;
}
}
async function loadFavorites(profileId: number) {
@@ -136,14 +162,6 @@
void loadAllMore();
});
function setAllSort(next: AllSort) {
if (next === allSort) return;
allSort = next;
if (typeof window !== 'undefined') localStorage.setItem('kochwas.allSort', next);
resetAllRecipes();
void loadAllMore();
}
// IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen.
$effect(() => {
if (typeof window === 'undefined') return;
@@ -340,6 +358,7 @@
async function dismissFromRecent(recipeId: number, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!requireOnline('Das Entfernen')) return;
recent = recent.filter((r) => r.id !== recipeId);
await fetch(`/api/recipes/${recipeId}`, {
method: 'PATCH',
@@ -500,7 +519,12 @@
<div class="listing-head">
<h2>Alle Rezepte</h2>
</div>
<div class="sort-chips" role="tablist" aria-label="Sortierung">
<div
class="sort-chips"
role="tablist"
aria-label="Sortierung"
bind:this={allChips}
>
{#each ALL_SORTS as s (s.value)}
<button
type="button"

View File

@@ -1,13 +1,14 @@
<script lang="ts">
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();
const items: { href: string; label: string; icon: typeof Icon }[] = [
{ href: '/admin/domains', label: 'Domains', icon: Globe },
{ 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>

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

View File

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

View File

@@ -35,7 +35,7 @@ const PatchSchema = z
.object({
title: z.string().min(1).max(200).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(),
prep_time_min: z.number().int().nonnegative().nullable().optional(),
cook_time_min: z.number().int().nonnegative().nullable().optional(),

View File

@@ -0,0 +1,30 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { insertRecipe } from '$lib/server/recipes/repository';
// Legt ein leeres Rezept an und gibt die ID zurück. Der Client leitet
// danach nach /recipes/{id}?edit=1 um, damit der Editor sofort offen ist.
// Titel "Neues Rezept" ist ein Platzhalter — der User überschreibt ihn
// beim ersten Speichern.
export const POST: RequestHandler = async () => {
const id = insertRecipe(getDb(), {
id: null,
title: 'Neues Rezept',
description: null,
source_url: null,
source_domain: null,
image_path: null,
servings_default: 4,
servings_unit: null,
prep_time_min: null,
cook_time_min: null,
total_time_min: null,
cuisine: null,
category: null,
ingredients: [],
steps: [],
tags: []
});
return json({ id });
};

View File

@@ -1,20 +1,88 @@
<script lang="ts">
import { CookingPot, Link } from 'lucide-svelte';
import { onMount, tick } from 'svelte';
import { CookingPot, Link, Plus, ChevronDown, Pencil } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { alertAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let filter = $state('');
let importUrl = $state('');
let menuOpen = $state(false);
let importOpen = $state(false);
let creatingBlank = $state(false);
let menuWrap: HTMLElement | undefined = $state();
let importInput: HTMLInputElement | undefined = $state();
function toggleMenu() {
menuOpen = !menuOpen;
}
async function openImport() {
menuOpen = false;
importOpen = true;
await tick();
importInput?.focus();
}
function closeImport() {
importOpen = false;
importUrl = '';
}
function submitImport(e: SubmitEvent) {
e.preventDefault();
const url = importUrl.trim();
if (!url) return;
if (!requireOnline('Der URL-Import')) return;
importOpen = false;
goto(`/preview?url=${encodeURIComponent(url)}`);
}
async function createBlank() {
if (creatingBlank) return;
if (!requireOnline('Das Anlegen')) return;
menuOpen = false;
creatingBlank = true;
try {
const res = await fetch('/api/recipes/blank', { method: 'POST' });
if (!res.ok) {
await alertAction({
title: 'Anlegen fehlgeschlagen',
message: `HTTP ${res.status}`
});
return;
}
const body = await res.json();
goto(`/recipes/${body.id}?edit=1`);
} finally {
creatingBlank = false;
}
}
function onDocClick(e: MouseEvent) {
if (!menuOpen) return;
if (menuWrap && !menuWrap.contains(e.target as Node)) menuOpen = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (importOpen) closeImport();
else if (menuOpen) menuOpen = false;
}
}
onMount(() => {
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onKey);
};
});
// Umlaute und Diakritika auf Basis-Buchstaben normalisieren, damit
// "apfel" auch "Äpfel" findet und "A/Ä/O/Ö/U/Ü" im gleichen Section-Header landen.
function normalize(s: string): string {
@@ -66,22 +134,86 @@
</script>
<header class="head">
<h1>Register</h1>
<p class="sub">{data.recipes.length} Rezepte insgesamt</p>
<div class="head-top">
<div class="head-titles">
<h1>Register</h1>
<p class="sub">{data.recipes.length} Rezepte insgesamt</p>
</div>
<div class="add-menu" bind:this={menuWrap}>
<button
type="button"
class="add-btn"
onclick={toggleMenu}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<Plus size={16} strokeWidth={2.2} />
<span>Rezept hinzufügen</span>
<ChevronDown size={14} strokeWidth={2.2} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<button type="button" role="menuitem" class="menu-item" onclick={openImport}>
<Link size={16} strokeWidth={2} />
<div class="menu-text">
<div class="menu-title">Von URL importieren</div>
<div class="menu-desc">Rezept aus einer Website ziehen</div>
</div>
</button>
<button
type="button"
role="menuitem"
class="menu-item"
onclick={createBlank}
disabled={creatingBlank}
>
<Pencil size={16} strokeWidth={2} />
<div class="menu-text">
<div class="menu-title">Leeres Rezept</div>
<div class="menu-desc">Manuell ausfüllen</div>
</div>
</button>
</div>
{/if}
</div>
</div>
</header>
<form class="import-url" onsubmit={submitImport}>
<span class="import-icon" aria-hidden="true"><Link size={16} strokeWidth={2} /></span>
<input
type="url"
bind:value={importUrl}
placeholder="Neues Rezept von URL importieren …"
aria-label="Rezept-URL importieren"
/>
<button type="submit" class="import-go" disabled={!importUrl.trim()}>
Importieren
</button>
</form>
{#if importOpen}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) closeImport();
}}
>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="import-title"
tabindex="-1"
>
<h2 id="import-title">Rezept-URL importieren</h2>
<form onsubmit={submitImport}>
<input
bind:this={importInput}
type="url"
bind:value={importUrl}
placeholder="https://…"
aria-label="Rezept-URL"
required
/>
<div class="modal-actions">
<button type="button" class="btn" onclick={closeImport}>Abbrechen</button>
<button type="submit" class="btn primary" disabled={!importUrl.trim()}>
Weiter
</button>
</div>
</form>
</div>
</div>
{/if}
<div class="filter-wrap">
<input
@@ -136,6 +268,16 @@
.head {
padding: 1.25rem 0 0.5rem;
}
.head-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.head-titles {
min-width: 0;
}
.head h1 {
margin: 0;
font-size: 1.6rem;
@@ -146,55 +288,145 @@
color: #666;
font-size: 0.9rem;
}
.import-url {
display: flex;
align-items: stretch;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
background: white;
border: 1px solid #cfd9d1;
border-radius: 12px;
padding: 0.25rem 0.25rem 0.25rem 0.75rem;
.add-menu {
position: relative;
flex-shrink: 0;
}
.import-url:focus-within {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
}
.import-icon {
.add-btn {
display: inline-flex;
align-items: center;
color: #6a7670;
}
.import-url input {
flex: 1;
border: 0;
background: transparent;
font-size: 0.95rem;
padding: 0.5rem 0.25rem;
min-height: 40px;
min-width: 0;
}
.import-url input:focus {
outline: none;
}
.import-go {
padding: 0 0.9rem;
gap: 0.5rem;
padding: 0.55rem 0.9rem;
background: #2b6a3d;
color: white;
border: 0;
border-radius: 10px;
font-size: 0.9rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
min-height: 40px;
flex-shrink: 0;
}
.import-go:hover:not(:disabled) {
.add-btn:hover {
background: #235532;
}
.import-go:disabled {
opacity: 0.45;
.menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
min-width: 260px;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 0.3rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.75rem;
background: transparent;
border: 0;
border-radius: 8px;
text-align: left;
cursor: pointer;
font-family: inherit;
color: #1a1a1a;
width: 100%;
}
.menu-item:hover:not(:disabled) {
background: #f4f8f5;
}
.menu-item:disabled {
opacity: 0.55;
cursor: progress;
}
.menu-item :global(svg) {
color: #2b6a3d;
flex-shrink: 0;
}
.menu-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.menu-title {
font-weight: 600;
font-size: 0.95rem;
}
.menu-desc {
color: #888;
font-size: 0.8rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(20, 30, 25, 0.45);
display: grid;
place-items: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: white;
border-radius: 14px;
padding: 1.1rem 1.1rem 1rem;
width: min(440px, 100%);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
}
.modal h2 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
color: #2b6a3d;
}
.modal input {
width: 100%;
padding: 0.7rem 0.85rem;
border: 1px solid #cfd9d1;
border-radius: 10px;
font-size: 1rem;
min-height: 44px;
font-family: inherit;
box-sizing: border-box;
}
.modal input:focus {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.85rem;
}
.modal .btn {
padding: 0.6rem 1rem;
min-height: 42px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
font-family: inherit;
}
.modal .btn:hover:not(:disabled) {
background: #f4f8f5;
}
.modal .btn.primary {
background: #2b6a3d;
color: white;
border: 0;
}
.modal .btn.primary:hover:not(:disabled) {
background: #235532;
}
.modal .btn.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.filter-wrap {

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import {
Heart,
@@ -19,6 +20,7 @@
import { profileStore } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
import type { CommentRow } from '$lib/server/recipes/actions';
let { data } = $props();
@@ -40,6 +42,24 @@
let saving = $state(false);
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: {
title: string;
description: string | null;
@@ -50,6 +70,7 @@
ingredients: typeof data.recipe.ingredients;
steps: typeof data.recipe.steps;
}) {
if (!requireOnline('Das Speichern')) return;
saving = true;
try {
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
@@ -108,6 +129,7 @@
});
return;
}
if (!requireOnline('Das Rating')) return;
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
@@ -126,16 +148,19 @@
});
return;
}
if (!requireOnline('Das Favorit-Setzen')) return;
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`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileId })
});
favoriteProfileIds = isFav
favoriteProfileIds = wasFav
? favoriteProfileIds.filter((id) => id !== profileId)
: [...favoriteProfileIds, profileId];
if (!wasFav) void firePulse('fav');
}
async function logCooked() {
@@ -146,6 +171,7 @@
});
return;
}
if (!requireOnline('Der Kochjournal-Eintrag')) return;
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -167,6 +193,7 @@
});
return;
}
if (!requireOnline('Das Speichern des Kommentars')) return;
const text = newComment.trim();
if (!text) return;
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
@@ -198,6 +225,7 @@
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Löschen')) return;
await fetch(`/api/recipes/${data.recipe.id}`, { method: 'DELETE' });
goto('/');
}
@@ -221,6 +249,7 @@
editingTitle = false;
return;
}
if (!requireOnline('Das Umbenennen')) return;
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
@@ -256,8 +285,10 @@
});
return;
}
if (!requireOnline('Das Wunschlisten-Setzen')) return;
const profileId = profileStore.active.id;
if (onMyWishlist) {
const wasOn = onMyWishlist;
if (wasOn) {
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, {
method: 'DELETE'
});
@@ -271,6 +302,7 @@
wishlistProfileIds = [...wishlistProfileIds, profileId];
}
void wishlistStore.refresh();
if (!wasOn) void firePulse('wish');
}
// Wake-Lock — Bildschirm beim Kochen nicht dimmen lassen.
@@ -315,6 +347,15 @@
}
onMount(() => {
// Wenn wir über "Manuell anlegen" hier landen, ist ?edit=1 gesetzt
// und wir starten direkt im Editor. Den Param danach aus der URL
// entfernen, damit Refresh nicht automatisch wieder edit-Mode ist.
if ($page.url.searchParams.get('edit') === '1') {
editMode = true;
const url = new URL(window.location.href);
url.searchParams.delete('edit');
history.replaceState(history.state, '', url.toString());
}
const stored = localStorage.getItem('kochwas.wakeLock');
if (stored !== null) wakeLockEnabled = stored === '1';
if (wakeLockEnabled) void acquireWakeLock();
@@ -377,11 +418,23 @@
{/if}
</div>
<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'} />
<span>Favorit</span>
</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}
<Check size={18} strokeWidth={2.5} />
<span>Auf Wunschliste</span>
@@ -580,11 +633,38 @@
color: #c53030;
border-color: #f1b4b4;
background: #fdf3f3;
--pulse-color: rgba(197, 48, 48, 0.45);
}
.btn.wish {
color: #2b6a3d;
border-color: #b7d6c2;
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 {
color: #b07e00;

View File

@@ -1,11 +1,18 @@
<script lang="ts">
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 { wishlistStore } from '$lib/client/wishlist.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';
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 loading = $state(true);
let sort = $state<SortKey>('popular');
@@ -35,6 +42,7 @@
});
return;
}
if (!requireOnline('Die Wunschlisten-Aktion')) return;
const profileId = profileStore.active.id;
if (entry.on_my_wishlist) {
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
@@ -59,6 +67,7 @@
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Entfernen')) return;
await fetch(`/api/wishlist/${entry.recipe_id}?all=true`, { method: 'DELETE' });
await load();
void wishlistStore.refresh();
@@ -80,15 +89,19 @@
<p class="sub">Das wollen wir bald mal essen.</p>
</header>
<div class="controls">
<label>
Sortieren:
<select bind:value={sort}>
<option value="popular">Am meisten gewünscht</option>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
</select>
</label>
<div class="sort-chips" role="tablist" aria-label="Sortierung">
{#each SORT_OPTIONS as s (s.value)}
<button
type="button"
role="tab"
aria-selected={sort === s.value}
class="chip"
class:active={sort === s.value}
onclick={() => (sort = s.value)}
>
{s.label}
</button>
{/each}
</div>
{#if loading}
@@ -131,7 +144,7 @@
aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
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}
<span class="count">{e.wanted_by_count}</span>
{/if}
@@ -162,24 +175,32 @@
margin: 0.2rem 0 0;
color: #666;
}
.controls {
.sort-chips {
display: flex;
justify-content: flex-end;
padding: 0.5rem 0 1rem;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.5rem 0 1rem;
}
.controls label {
display: inline-flex;
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;
.chip {
padding: 0.4rem 0.85rem;
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 {
color: #888;
@@ -284,9 +305,9 @@
color: #444;
}
.like.active {
color: #c53030;
background: #fdf3f3;
border-color: #f1b4b4;
color: #2b6a3d;
background: #eaf4ed;
border-color: #b7d6c2;
}
.del:hover {
color: #c53030;

View File

@@ -2,88 +2,258 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
import { resolveStrategy } from '$lib/sw/cache-strategy';
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 IMAGE_CACHE = `kochwas-images-v1`;
const APP_ASSETS = [...build, ...files];
const SHELL_CACHE = `kochwas-shell-${version}`;
const DATA_CACHE = 'kochwas-data-v1';
const IMAGES_CACHE = 'kochwas-images-v1';
sw.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_CACHE).then((cache) => cache.addAll(APP_ASSETS))
);
// Activate new worker without waiting for old clients to close.
void sw.skipWaiting();
});
// App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt)
const SHELL_ASSETS = [...build, ...files];
sw.addEventListener('activate', (event) => {
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k.startsWith('kochwas-app-') && k !== APP_CACHE)
.map((k) => caches.delete(k))
);
await sw.clients.claim();
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.
})()
);
});
sw.addEventListener('fetch', (event) => {
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Alte Shell-Caches (vorherige Versionen) räumen
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k.startsWith('kochwas-shell-') && k !== SHELL_CACHE)
.map((k) => caches.delete(k))
);
await self.clients.claim();
})()
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
if (new URL(req.url).origin !== self.location.origin) return; // Cross-Origin unangetastet
const url = new URL(req.url);
if (url.origin !== location.origin) return;
const strategy = resolveStrategy({ url: req.url, method: req.method });
if (strategy === 'network-only') return;
// Images served from /images/* — cache-first with background update
if (url.pathname.startsWith('/images/')) {
event.respondWith(
(async () => {
const cache = await caches.open(IMAGE_CACHE);
const cached = await cache.match(req);
const network = fetch(req)
.then((res) => {
if (res.ok) void cache.put(req, res.clone());
return res;
})
.catch(() => undefined);
return cached ?? (await network) ?? new Response('Offline', { status: 503 });
})()
);
return;
}
// App shell assets (build/* and static files) — cache-first
if (APP_ASSETS.includes(url.pathname)) {
event.respondWith(
(async () => {
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
if (req.destination === 'document') {
event.respondWith(
(async () => {
try {
const res = await fetch(req);
const cache = await caches.open(APP_CACHE);
if (res.ok) void cache.put(req, res.clone());
return res;
} catch {
const cached = await caches.match(req);
return cached ?? new Response('Offline', { status: 503 });
}
})()
);
if (strategy === 'shell') {
event.respondWith(cacheFirst(req, SHELL_CACHE));
} else if (strategy === 'images') {
event.respondWith(cacheFirst(req, IMAGES_CACHE));
} else if (strategy === 'swr') {
event.respondWith(staleWhileRevalidate(req, DATA_CACHE));
}
});
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
if (hit) return hit;
const fresh = await fetch(req);
if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {});
return fresh;
}
async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
const fetchPromise = fetch(req)
.then((res) => {
if (res.ok) cache.put(req, res.clone()).catch(() => {});
return res;
})
.catch(() => hit ?? Response.error());
return hit ?? fetchPromise;
}
const META_CACHE = 'kochwas-meta';
const MANIFEST_KEY = '/__cache-manifest__';
const PAGE_SIZE = 50; // /api/recipes/all limitiert auf 50
const CONCURRENCY = 4;
type RecipeSummary = { id: number; image_path: string | null };
self.addEventListener('message', (event) => {
const data = event.data as { type?: string } | undefined;
if (!data) return;
if (data.type === 'sync-start') {
event.waitUntil(runSync(false));
} else if (data.type === 'sync-check') {
event.waitUntil(runSync(true));
} else if (data.type === 'SKIP_WAITING') {
// Wird vom pwaStore nach User-Klick auf "Neu laden" geschickt.
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",
"sizes": "any",
"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 { fileURLToPath } from 'node:url';
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';
const here = dirname(fileURLToPath(import.meta.url));
@@ -61,17 +60,9 @@ afterEach(async () => {
});
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();
// note: no domain added
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');
// keine Domain in der Whitelist — preview muss trotzdem klappen
const r = await previewRecipe(db, `${baseUrl}/recipe`);
expect(r.title.toLowerCase()).toContain('schupfnudel');
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 () => {
const db = openInMemoryForTest();
addDomain(db, '127.0.0.1');
await expect(previewRecipe(db, `${baseUrl}/bare`)).rejects.toMatchObject({
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', () => {
it('imports, persists, and is idempotent', async () => {
const db = openInMemoryForTest();
addDomain(db, '127.0.0.1');
const first = await importRecipe(db, imgDir, `${baseUrl}/recipe`);
expect(first.duplicate).toBe(false);
expect(first.id).toBeGreaterThan(0);
@@ -104,9 +100,9 @@ describe('importRecipe', () => {
expect(second.id).toBe(first.id);
});
it('surfaces ImporterError type', async () => {
it('surfaces ImporterError type when no recipe on page', async () => {
const db = openInMemoryForTest();
await expect(importRecipe(db, imgDir, `${baseUrl}/recipe`)).rejects.toBeInstanceOf(
await expect(importRecipe(db, imgDir, `${baseUrl}/bare`)).rejects.toBeInstanceOf(
ImporterError
);
});

View File

@@ -8,7 +8,6 @@ import {
updateDomain,
getDomainById
} from '../../src/lib/server/domains/repository';
import { isDomainAllowed } from '../../src/lib/server/domains/whitelist';
describe('allowed domains', () => {
it('round-trips domains', () => {
@@ -19,18 +18,10 @@ describe('allowed domains', () => {
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();
addDomain(db, 'WWW.Chefkoch.DE');
expect(isDomainAllowed(db, 'https://chefkoch.de/abc')).toBe(true);
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);
expect(listDomains(db)[0].domain).toBe('chefkoch.de');
});
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);
});
});