64 Commits

Author SHA1 Message Date
hsiegeln
fb7c2f0e9b feat(photo-upload): zwei Buttons fuer Kamera vs. Datei-Picker
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
Android-Chrome auf Tablet verhaelt sich zickig: mit capture="environment"
nur Kamera, ohne capture nur Datei-Picker -- nie beide. Zwei separate
Buttons (mit jeweils eigenem Input-Element) machen die Wahl explizit
und funktionieren ueberall eindeutig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:45:37 +02:00
hsiegeln
33ee6fbf2e feat(photo-upload): Picker ohne capture -> auch gespeicherte Fotos
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m26s
capture="environment" zwang Mobile-Browser in den Kamera-Modus. Ohne
das Attribut zeigt der Browser auf Mobile die volle Auswahl
(Kamera / Fotomediathek / Datei) -- besser fuer Tablets und User,
die ein schon existierendes Kochbuch-Foto verwenden wollen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:39:07 +02:00
hsiegeln
e2713913e7 feat(photo-upload): Logging fuer Upload-Parse-Fehler
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Der bisherige Endpoint verschluckte den formData()-Fehler mit einem
generischen "Multipart erwartet" — wir wissen nicht, warum Chrome auf
dem Tablet scheitert. Jetzt wird beim Fehler Content-Type, -Length und
User-Agent geloggt, plus die konkrete Error-Message in der Response.
Kein Foto-Inhalt im Log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:37:42 +02:00
hsiegeln
3bc7fa16e2 feat(photo-upload): Limits hochschrauben fuer Tablet-Fotos
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
Tablet- und iPad-Pro-Kameras liefern JPEGs/HEICs bis 15 MB. Mit den
alten 8-/10-MB-Limits scheiterte das Upload beim SvelteKit-Body-Parser
mit "Multipart erwartet" (undurchsichtiger Fehler, weil SvelteKit den
Body frueher abweist als unser Endpoint-Check).

- Endpoint MAX_BYTES: 8 -> 20 MB
- BODY_SIZE_LIMIT: 10 -> 25 MB (mit Multipart-Overhead)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:31:34 +02:00
hsiegeln
173d9d138d fix(ai): sharp via createRequire, nicht ES-Import
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 28s
ES-Dynamic-Import mit @vite-ignore reichte nicht -- adapter-node's
Rollup-Step extrahiert sharp trotzdem in einen shared chunk und
bundelt sharp's interne dynamic-requires kaputt.

createRequire(import.meta.url) plus require('sharp') ist pure Node-
Runtime-Logik, die Rollup komplett ignoriert. sharp wird regulaer aus
node_modules geladen -- inkl. seiner Plattform-.node-Binary aus
@img/sharp-linuxmusl-arm64.

Verifikation: Build-Output enthaelt 0 Vorkommen von "dynamicRequireTargets"
und "sharp.node" (waren vorher in einem 319KB shared chunk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:08:53 +02:00
hsiegeln
5492d4dc24 fix(deploy): BODY_SIZE_LIMIT=10MB fuer Foto-Upload
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m17s
adapter-node limitiert Request-Bodies per Default auf 512 KB.
Unsere Rezept-Fotos sind bis 8 MB gross -- der Upload scheitert
sonst vor dem Endpoint-Check mit "Multipart body erwartet", weil
SvelteKit den Body frueher abweist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:05:09 +02:00
hsiegeln
39de08abf9 fix(ai): sharp via dynamic import, nicht top-level
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m28s
Der vorige Versuch mit ssr.external in vite.config.ts war ein No-op:
adapter-node macht einen eigenen Rollup-Bundle-Schritt nach Vite und
ignoriert ssr.external komplett. Ergebnis: sharp's dynamic-require
fuer die native .node-Binary landet kaputt im Server-Bundle (332KB
Bundle-Chunk, 297 sharp-Referenzen).

Dynamic import mit /* @vite-ignore */ verhindert, dass Rollup sharp
aufloest — die Require geht stattdessen zur Laufzeit regulaer an
Node und findet @img/sharp-linuxmusl-arm64 in node_modules.

Ergebnis lokal: Server-Chunk von 332KB auf 14KB geschrumpft, nur noch
2 Referenzen auf den Paketnamen (der Import-String selbst).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:33:59 +02:00
hsiegeln
fd7884e1b2 fix(vite): sharp als ssr.external markieren
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m17s
Der Server-Bundle-Schritt (Rollup via adapter-node) kann sharp's
dynamic-require fuer die native Plattform-.node-Binary nicht aufloesen
und bundelt kaputten Code ins Image. ssr.external sorgt dafuer, dass
sharp zur Laufzeit regulaer aus node_modules geladen wird, wo der
Docker-Build die @img/sharp-linuxmusl-arm64-Binary korrekt abgelegt hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:27:28 +02:00
hsiegeln
13728f9252 fix(docker): expliziter Plattform-Install fuer sharp-Prebuilts
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m5s
Finaler Anlauf gegen den arm64-Build-Fehler. Bisher scheiterte
npm daran, @img/sharp-linuxmusl-arm64 unter Docker-Buildx-QEMU zu
installieren, trotz --include=optional. Mehrschichtiger Fix:

1. --cpu=arm64 --os=linux --libc=musl auf npm install: umgeht QEMU-
   bezogene Detection-Bugs (sharp's offiziell empfohlener Fix).
2. Expliziter Zusatz-Install von @img/sharp-linuxmusl-arm64 und
   @img/sharp-libvips-linuxmusl-arm64: zwingt die Prebuilts auf Disk,
   unabhaengig von der Lockfile-Resolution.
3. --ignore-scripts beim Install + npm rebuild danach: loest den Race,
   wo sharp's postinstall laeuft bevor der Prebuilt-Tarball fertig ist.
4. node-addon-api + node-gyp als Runtime-Deps: from-source-Build-
   Fallback falls alle Prebuilt-Pfade scheitern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:52:55 +02:00
hsiegeln
83f5b88d94 fix(docker): node-addon-api + ignore-scripts/rebuild fuer sharp
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 51s
Drei Schichten Absicherung gegen den arm64-Build-Fehler:

- --ignore-scripts beim npm install verhindert, dass sharp's postinstall
  check.js laeuft, bevor das @img/sharp-linuxmusl-arm64-Paket entpackt
  ist (Race in parallelem Install).
- npm rebuild danach: alle Deps sind jetzt auf Disk, Postinstalls laufen
  sauber in Dependency-Reihenfolge.
- node-addon-api als Runtime-Dep: falls die Prebuilt-Binary im npm-Tree
  nicht landet, kann sharp from-source bauen (vips-dev + python3 + make
  + g++ sind im Dockerfile bereits installiert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:49:41 +02:00
hsiegeln
cb93725139 fix(docker): npm install statt npm ci fuer sharp-Prebuilts
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 48s
Der vorige Fix (ignore-scripts + rebuild, plus Fresh-ci im Builder) hat
den sharp-Prebuilt trotzdem nicht installiert. Ursache: der Windows-
generierte Lockfile markiert @img/sharp-linuxmusl-arm64 als "dev": true,
sodass npm ci die Prebuilt-Binary konsistent auslaesst — egal ob mit
--include=optional. npm install dagegen resolvt Optional-Deps frisch fuer
die Build-Plattform (linux-arm64-musl im Docker) und findet die Prebuilts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:46:55 +02:00
hsiegeln
80c72b6e5b fix(docker): sharp-Prebuilts beim CI-Build korrekt installieren
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 51s
Zwei Fixes gegen den arm64-Build-Fehler:

1. npm ci mit --ignore-scripts + npm rebuild danach — vermeidet
   eine Race, bei der sharp's postinstall check.js laeuft bevor die
   Plattform-Prebuilt-Binary vollstaendig entpackt ist.

2. Statt npm prune --omit=dev ein Fresh-Install via npm ci
   --omit=dev. Grund: Der Lockfile wird auf Windows generiert und
   markiert die linux-musl-arm64-Prebuilts als "dev": true, obwohl
   sie fuer's Runtime gebraucht werden. Prune wuerde sie kappen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:44:05 +02:00
hsiegeln
b88f1fbfa4 chore(release): v1.3.0 — Foto-Rezept-Magie
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 1m31s
- Kamera-Icon im Header (bei gesetztem GEMINI_API_KEY, disabled offline)
- /new/from-photo: File-Picker oder Kamera -> Gemini-Extraktion -> vorbefuellter Editor
- POST /api/recipes/extract-from-photo (sharp preprocess + Gemini 2.5 Flash, keine Persistenz)
- POST /api/recipes fuer Scratch-Insert nach Editor-Save
- 50er Pool deutscher Magie-Phrasen fuer description
- docs, CLAUDE.md, OPERATIONS, ARCHITECTURE aktualisiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:51:52 +02:00
hsiegeln
f4aefb8e99 docs: Foto-Rezept-Magie in OPERATIONS/ARCHITECTURE/CLAUDE 2026-04-21 10:51:23 +02:00
hsiegeln
6dab36339a test(e2e): Foto-Import Happy-Path und Offline-Icon 2026-04-21 10:50:01 +02:00
hsiegeln
eea5fb7560 feat(ui): Camera-Icon im Header mit Gemini-Config- und Offline-Gate 2026-04-21 10:48:38 +02:00
hsiegeln
47e91de0a1 feat(ui): /new/from-photo Page mit File-Picker, Lade- und Fehler-States 2026-04-21 10:47:33 +02:00
hsiegeln
bc42f35f8c feat(client): PhotoUploadStore mit idle/loading/success/error 2026-04-21 10:45:36 +02:00
hsiegeln
8c23875ba2 feat(editor): Bild-Block skip wenn recipe.id === null 2026-04-21 10:44:48 +02:00
hsiegeln
06e60afc88 feat(api): POST /api/recipes fuer Scratch-Insert aus Foto-Import 2026-04-21 10:43:30 +02:00
hsiegeln
e01f15a2a6 feat(api): POST /api/recipes/extract-from-photo 2026-04-21 10:42:46 +02:00
hsiegeln
3f259a7870 feat(ai): simpler In-Memory-Ratelimiter pro IP 2026-04-21 10:41:16 +02:00
hsiegeln
904edcb3ff feat(ai): Gemini-Client mit Timeout, 1x-Retry und Fehler-Codes 2026-04-21 10:40:58 +02:00
hsiegeln
d479fd61d8 feat(ai): Extraction-Prompt + Gemini-Schema + Zod-Validator 2026-04-21 10:40:03 +02:00
hsiegeln
0cca9a699c feat(ai): image-preprocess mit sharp (Resize + JPEG + EXIF-Strip) 2026-04-21 10:39:22 +02:00
hsiegeln
c284f4b85b feat(ai): 50er-Pool Magie-Phrasen fuer Foto-description 2026-04-21 10:38:32 +02:00
hsiegeln
9e3d6e8d01 chore(deps): @google/generative-ai + vips-dev fuer Foto-Rezept-Magie 2026-04-21 10:37:12 +02:00
hsiegeln
783b782608 docs: implementation plan fuer Foto-Rezept-Magie
15 bite-sized tasks mit TDD-Struktur, von deps+env ueber
Gemini-Client, API-Endpoints bis UI und Release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:35:36 +02:00
hsiegeln
1532880cd5 docs: 50er-Phrasenpool fuer Foto-Rezept-description
Random-Auswahl server-seitig nach AI-Call; description steht
nicht im Gemini-Schema, keine Halluzinationsflaeche.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:22:52 +02:00
hsiegeln
aa7f0eff11 docs: spec fuer Foto-Rezept-Magie (v1.3)
Design-Spec fuer Gemini-basierten Foto->Rezept-Import:
Kamera-Icon im Header, Extraktion auf Server, Editor-Prefill
ohne DB-Record, Foto wird nicht persistiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:16:35 +02:00
hsiegeln
26018eee7f chore: .prettierignore fuer Fixtures, Docs und Templates
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 31s
npm run format hat zuletzt 18k Zeilen HTML-Fixture und alle
Markdown-Plaene angefasst. Ignore-Liste deckt jetzt ab:

- tests/fixtures (byte-exakte HTML-Captures fuer Parser-Tests)
- *.md (hand-aligned Tabellen, historische Plan-Artefakte)
- searxng/settings.yml (Template mit VAR-Platzhaltern)
- data/, build/, .svelte-kit, node_modules, Lockfile

Damit bleibt npm run format auf Code beschraenkt.
2026-04-20 08:45:41 +02:00
hsiegeln
24bd9c1d1b feat(header): Versionsnummer unter dem Logo
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Liest KOCHWAS_TAG via +layout.server.ts aus $env/dynamic/private
und zeigt den Tag als kleine graue Zeile unter dem Brand-Text auf
der Startseite. Fallback "dev" wenn nicht gesetzt. Auf engen
Screens mit ausgeblendetem Brand verschwindet auch die Version.

docker-compose.prod.yml reicht die Host-Env-Variable jetzt in den
Container durch (vorher nur fuers Image-Tag-Binding interpoliert).
2026-04-20 08:41:18 +02:00
hsiegeln
633e497bdc fix(sw): network-first + 3s timeout statt SWR fuer Daten
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
SWR lieferte bei jedem Cache-Hit sofort die alte Antwort und
aktualisierte das Cache nur fuer den naechsten Request. Folge:
UI zeigte stale Daten, frische Daten erst nach Refresh.

Neu: network-first mit 3 s Timeout-Fallback. Netz gewinnt bei
frischer Antwort; Timeout oder Netzwerk-Fehler fallen auf Cache
zurueck. Pre-Cache-Logik (runSync) bleibt unveraendert, Shell
und Bilder bleiben cache-first.
2026-04-20 08:29:00 +02:00
hsiegeln
b5c01b950e chore(release): v1.2.0 + Doku-Aktualisierung
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
Release-Bundle fuer v1.2.0. Inhaltliche Highlights seit v1.1.0:
- Post-Review-Roadmap: API-Helper, Trash-Kommentar-Delete, Preview-
  Guard, untrack()-Snapshots, CSS-Var --pill-radius, asyncFetch-
  Wrapper, requireProfile(message), Code-Cleanup
- Remote-E2E-Suite (tests/e2e/remote/) gegen kochwas-dev.siegeln.net
  inkl. CRUD, Profile-Fixtures, API-Cleanup-Helpers, serviceWorkers-
  block fuer Chromium-Stabilitaet
- SearchStore (src/lib/client/search.svelte.ts) — gemeinsamer
  Live-Search-Store fuer Header-Dropdown und Startseite mit Debounce,
  Race-Guard, Pagination, Web-Fallback, Snapshot/Restore
- Editor-Split: RecipeEditor in IngredientRow, StepList,
  ImageUploadBox, TimeDisplay + recipe-editor-types zerlegt
- Zutaten-Sektionen: Migration 012 + section_heading-Feld,
  Inline-Insert-Button im Editor, Heading-Rendering in RecipeView,
  4 neue Remote-E2E-Tests mit CRUD-Coverage

Doku-Updates:
- ARCHITECTURE.md: Component-Liste, SearchStore-Erwaehnung,
  section_heading-Semantik, Test-Strategie um E2E local+remote
- OPERATIONS.md: Dev-System kochwas-dev.siegeln.net dokumentiert
- CLAUDE.md: Datei-Map auf Sub-Components ausgeweitet, Stand-
  Abschnitt auf aktuelle Roadmap-Stufen aktualisiert
- package.json / package-lock.json: 0.1.0 -> 1.2.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:34:01 +02:00
hsiegeln
6bde3909d8 polish(sections): Muelltonne statt X + Ueberschrift groesser/fetter
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m22s
- IngredientRow: Sektion-entfernen-Button nutzt Trash2 (konsistent
  mit dem Zutat-Entfernen-Button daneben)
- RecipeView: section-heading von 1rem/600 auf 1.2rem/700, mehr
  vertikaler Abstand fuer deutlichere optische Trennung
- E2E-Spec: type-inference-Trick durch APIRequestContext-Import
  ersetzt (svelte-check stolperte bei typeof test mit TestDetails-
  Overload)
- Plan-Datei der Feature-Session mitcommitet

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:26:39 +02:00
hsiegeln
78c4f56992 Merge ingredient-sections — Zutaten-Gruppierung via section_heading
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 39s
- Migration 012: ingredient.section_heading TEXT NULL
- Editor: Inline-Abschnitt-hinzufuegen-Button (fade-in on hover) vor
  jeder Zeile; Heading-Input + X-Entfernen-Button wenn gesetzt
- View: <li class="section-heading"> vor erster Zutat jeder Sektion
- Scaler preserviert section_heading via Spread
- E2E-Suite: 4 neue Tests mit CRUD gegen kochwas-dev (46/46 gruen)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:19:39 +02:00
hsiegeln
c07d2f99ad test(e2e): Zutaten-Sektionen CRUD + UI-Flow auf kochwas-dev
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 40s
4 new remote specs: API roundtrip, editor add-section + view render,
section remove, empty heading -> null on save. All 46 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:19:13 +02:00
hsiegeln
8069c5c246 feat(view): Zutaten-Sektionen als Ueberschriften rendern
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:08:43 +02:00
hsiegeln
7d6ee04fec feat(editor): Sektionen-Handler + save-Patch mit section_heading
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:06:12 +02:00
hsiegeln
b646720a6e fix(editor): :global(.ing-list):hover damit Fade-in wirklich greift 2026-04-19 15:04:26 +02:00
hsiegeln
526c7433f4 feat(editor): Sektionsueberschriften in IngredientRow + Insert-Button
DraftIng bekommt section_heading: string | null. IngredientRow
rendert davor einen Fade-in-Insert-Button (null) oder ein Heading-
Input mit Entfernen-Button (string). Props onaddSection/onremoveSection
ergaenzt; Styles an bestehendem Block angehaengt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:03:29 +02:00
hsiegeln
96cb55495e test(scaler): section_heading ueberlebt Skalierung
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:00:21 +02:00
hsiegeln
a1baf7f30a feat(db): section_heading roundtrip in recipe-repository
INSERT/SELECT in insertRecipe, replaceIngredients und getRecipeById
um section_heading ergänzt. IngredientSchema im PATCH-Endpoint sowie
Ingredient-Fixtures in search-local-, scaler- und repository-Tests
auf das neue Pflichtfeld aktualisiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:55:46 +02:00
hsiegeln
b0d5f921e2 docs(migration): 012 Kommentar an 010/011-Stil angleichen (DE, Begruendung) 2026-04-19 14:52:13 +02:00
hsiegeln
72816d6b35 feat(schema): ingredient.section_heading (Migration 012 + Type)
Fuegt das nullable Feld section_heading zur ingredient-Tabelle hinzu
(Migration 012), erweitert den Ingredient-Typ und aktualisiert alle drei
Return-Stellen in parseIngredient. Downstream-Sites (repository, Editor,
Tests) bleiben rot – werden in Task 2+ behoben.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:49:42 +02:00
hsiegeln
ad5a6afcd9 Merge editor-split — Tier 4 Item B + E2E-Stabilitaet
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s
4 Sub-Components extrahiert: ImageUploadBox (190 L), IngredientRow
(129 L), StepList (101 L), TimeDisplay (30 L) plus recipe-editor-
types.ts (8 L). RecipeEditor.svelte 628→312 L, RecipeView.svelte
398→387 L. 196/196 Unit-Tests, svelte-check 0 Errors.

Bonus: Playwright-Remote-Suite jetzt stabil 42/42 — Chromium-Crash-
Cascade durch serviceWorkers:block behoben.
2026-04-19 14:15:19 +02:00
hsiegeln
30a409fd16 fix(e2e): serviceWorkers=block behebt Chromium-Crash-Cascade
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Die Remote-Suite hatte `serviceWorkers: allow` gesetzt, jeder Test
registriert einen frischen SW im neuen Context. Nach 20-30 Specs
akkumuliert das im Single-Worker-Run genug Browser-State, dass
Chromium mitten in der Suite crasht — alle folgenden Tests fallen
dann mit "browser.newContext closed" als Cascade.

'block' entfernt den SW komplett. Diese Suite testet nur Live-API-
Verhalten gegen den Server, keine PWA-Features (dafuer ist
offline.spec.ts lokal zustaendig). Full-Run jetzt stabil 42/42,
Laufzeit zusaetzlich ~3s schneller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:09:32 +02:00
hsiegeln
504fbb6cc6 refactor(view): TimeDisplay als eigenstaendige Component
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
timeSummary-Formatierung in eine wiederverwendbare Component
gezogen. RecipeView liefert nur noch die drei Werte — zukuenftige
Call-Sites (Preview, Hover-Cards) koennen dieselbe Logik reusen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:50:46 +02:00
hsiegeln
d50841c5a6 refactor(editor): StepList als eigenstaendige Component
Zubereitungs-Liste mit Add + Remove als Sub-Component. Parent steuert
nur noch den Wrapper und reicht steps + die zwei Callbacks rein.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:45:56 +02:00
hsiegeln
defbb5e24d refactor(editor): IngredientRow + shared types
IngredientRow rendert eine einzelne editierbare Zutat-Zeile. DraftIng
und DraftStep sind jetzt in recipe-editor-types.ts, damit Parent und
Sub-Components auf dieselbe Form referenzieren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:40:10 +02:00
hsiegeln
c43b1dca87 refactor(editor): ImageUploadBox als eigenstaendige Component
Isoliert den Bild-Upload-Flow (File-Input, Preview, Entfernen-Dialog)
aus dem RecipeEditor. Parent haelt nur noch den <section>-Wrapper und
reicht recipe.id + image_path rein, kriegt Aenderungen per onchange
callback zurueck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:33:26 +02:00
hsiegeln
015cb432fb docs(plans): Editor-Split Implementierungsplan (Tier 4 Item B)
5-Task-Plan fuer 4 Sub-Components: ImageUploadBox, IngredientRow,
StepList, TimeDisplay. Parent-owned state bleibt im Parent, Sub-
Components rendern bare Content damit Parent-Scoped-CSS greift.
Keine Component-Unit-Tests (etablierter Codebase-Stil), Manual-
Smoke + existierende e2e-Specs decken Regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:28:30 +02:00
hsiegeln
f273942286 Merge search-state-store — Tier 2 Post-Review-Roadmap
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
SearchStore extrahiert aus +page.svelte (808→645) und +layout.svelte
(681→569). 12 neue Unit-Tests (196 total), 40/42 E2E grün (1 Flake,
1 Skip). Keine Regression in UAT auf kochwas-dev.
2026-04-19 13:18:04 +02:00
hsiegeln
c45ef2a613 fix(search): runSearch bricht pending Debounce ab
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Enter waehrend Debounce-Fenster feuerte bislang eine zweite Fetch
fuer dieselbe Query. Race-Guard greift nicht, weil q identisch ist.
runSearch clearTimeout am Anfang behebt's, neuer Unit-Test sichert es.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:03:42 +02:00
hsiegeln
e7067971a5 refactor(home): Live-Search auf SearchStore migriert
Entfernt die duplizierten $state-Felder, runSearch, loadMore und
beide Debounce-Effekte. URL-Sync, Snapshot und Filter-Re-Search
bleiben hier — delegieren aber an den Store. All-Recipes-Infinite-
Scroll unberuehrt (separate UI-Concern).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:57:58 +02:00
hsiegeln
0ca42f3329 refactor(layout): Header-Dropdown nutzt SearchStore
Ersetzt die 10 lokalen $state-Felder, den Debounce-$effect und die
lokalen Search-Funktionen durch eine SearchStore-Instanz. Nav-Open-
Toggle, Click-outside und Menu-State bleiben lokal — UI-Concerns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:51:11 +02:00
hsiegeln
4b17f19038 docs(plans): Plan-Doc auf runDebounced() ohne Parameter angleichen
Consumer-Patterns (Task 3/4) aktualisiert: $effect liest store.query
explizit und ruft runDebounced() parameterlos — matcht die live Impl
nach Commit 4edddc3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:48:50 +02:00
hsiegeln
4edddc38e3 refactor(search): runDebounced ohne missweisenden Parameter
Der _q-Parameter wurde nie benutzt — Consumer sollen stattdessen
store.query im \$effect lesen, dann runDebounced() callen. Weniger
Footgun, explizitere Call-Site.

Tests-Rename: "mid-flight" → "cleared/changed", beschreibt was der
Test tatsaechlich absichert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:47:40 +02:00
hsiegeln
fc47c78397 fix(search): Race-Guard-Test korrekt auf in-flight abzielen
Der vorherige Test setzte query NACH dem Fetch-Abschluss und erzwang
dafuer einen setter-Side-Effect, der bei normalem Tippen die Treffer
waehrend des Debounce-Fensters fuer 300ms leer geblitzt haette.

Jetzt: echter Race-Test mit manuell aufloesbarem fetch. Setter-Nebenwirkung
entfernt, query ist wieder plain \$state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:41:43 +02:00
hsiegeln
58ce19c160 feat(search): SearchStore fuer Live-Search mit Web-Fallback
Extrahiert die duplizierte Such-Logik aus +page.svelte und
+layout.svelte in eine gemeinsame Klasse. Pure Datenschicht
mit injizierbarem fetch — UI-Concerns (URL-Sync, Dropdown,
Snapshot) bleiben in den Komponenten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:38:33 +02:00
hsiegeln
7fd90643c5 docs(plans): Search-State-Store Implementierungsplan
6-Task-Plan fuer Tier 2 der Post-Review-Roadmap. Extrahiert die
duplizierte Such-Logik aus +page.svelte und +layout.svelte in eine
gemeinsame SearchStore-Klasse mit TDD (12 Unit-Tests), Header-
Dropdown-Migration vor Home-Migration, und UAT-Smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:32:50 +02:00
hsiegeln
3021ccb6a9 fix(e2e): 3 Specs robuster gegen reale Runtime
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 31s
- comments: Loeschen-Button im ConfirmDialog war ambig (3 Matches —
  Rezept-Delete, Kommentar-Trash, Dialog-Bestaetigung). Locator auf
  getByRole('dialog', { name: /Kommentar löschen/i }) eingeschraenkt.
- recipe-detail Portionen: getByText(/\b750 g/) trifft nicht wegen
  Whitespace-Layout im <span class="qty">. Auf
  locator('.ing-list li', { hasText: 'Hähnchenbrustfilet' })
  .toContainText('750 g') umgestellt — robust gegenueber Svelte-
  Whitespace-Quirks.
- search empty-state: SearXNG matcht loose, "truly empty" ist nicht
  zuverlaessig reproduzierbar. Test akzeptiert jetzt "Empty-State ODER
  Web-Fallback" und prueft zusaetzlich, dass kein JS-Error fliegt.

admin/backup war eine transiente Flake — 15 Repeat-Runs alle gruen,
kein Code-Fix noetig.

Gate: 12/12 der geaenderten Specs passed local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:21:36 +02:00
hsiegeln
a7ad159c69 test(e2e): Playwright Smoketests gegen kochwas-dev (remote)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m42s
Automatisierte End-to-End-Tests gegen ein deployed Environment. Loest
die manuellen MCP-Playwright-Runs ab. 42 Tests in 9 Files:

- homepage: H1, Sektionen, Sort-Tabs, Console-Errors
- search: lokaler Treffer, Web-Fallback, Empty-State, Deep-Link
- profile: Switcher, Auswahl-Persistenz, Favoriten-Section, Guard-Dialog
- recipe-detail: Header, Portionen-Scaling (4->6), Favorit-Toggle,
  Rating-Persistenz ueber Reload, Gekocht-Counter, Wunschliste-Toggle
- comments: eigenen erstellen+loeschen via UI, fremder hat kein Delete
- wishlist: Seite, Sort-Tabs, Badge-Sync, requireProfile-Custom-Message
- preview: Guard ohne ?url=, echte URL parst, unparsbare zeigt error-box
- admin: alle 4 Subrouten + /admin redirect
- api-errors: parsePositiveIntParam (4x Invalid id), validateBody (4x
  Invalid body + issues), 404, Sanity /health /profiles /domains

Architektur:
- Separate playwright.remote.config.ts (getrennt von local preview)
- workers: 1 + afterEach API-Cleanup (rating, favorite, wishlist, comments)
- Hardcoded Recipe-ID 66 + Profile 1/2/3 — stabile Dev-DB-Seeds
- E2E_REMOTE_URL ueberschreibt die Ziel-URL

Ausfuehrung: npm run test:e2e:remote

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:14:04 +02:00
hsiegeln
7da37d0a3d Merge cleanup-batch-post-review — Tier 1 + 2 UAT-Fixes
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s
Sechs atomare Commits aus der Post-Review-Roadmap:
- I: RecipeEditor form-lokale Snapshots via untrack() (10 svelte-check
  WARNINGs weg)
- H: Bild-Upload/Delete auf asyncFetch Wrapper
- F: --pill-radius CSS-Variable (15 Sites dedupliziert)
- G: requireProfile(message?) mit optionalem Parameter
- Preview-Guard wenn ?url= fehlt (UAT-Finding)
- Kommentar-Delete-Button fuer eigene Kommentare (UAT-Finding)

Alles 184/184 Tests gruen, svelte-check 0 Warnings, UAT auf
kochwas-dev durchgeklickt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:05:11 +02:00
74 changed files with 9600 additions and 1151 deletions

View File

@@ -15,3 +15,9 @@ BRAVE_API_KEY=
# SearXNG-Secret: beliebig lange Zufallskette. Für Prod mit
# `openssl rand -hex 32` generieren und in der Pi-.env ablegen.
SEARXNG_SECRET=dev-secret-change-me
# Gemini Vision (Foto-Rezept-Magie). Ohne Key ist die Funktion graceful
# deaktiviert — der Kamera-Button erscheint dann gar nicht erst.
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash
GEMINI_TIMEOUT_MS=20000

1
.gitignore vendored
View File

@@ -7,4 +7,5 @@ data/
*.log
test-results/
playwright-report/
playwright-report-remote/
.playwright-mcp/

24
.prettierignore Normal file
View File

@@ -0,0 +1,24 @@
# Generierte / Build-Artefakte
node_modules
.svelte-kit
build
coverage
.vite
# Lockfiles
package-lock.json
# Lokale Laufzeit-Daten
data
# Test-Fixtures: rohe HTML-Captures muessen byte-exakt bleiben,
# sonst schlaegt die JSON-LD-Extraktion im Parser-Test anders an.
tests/fixtures
# Markdown: Tabellen sind hand-aligned, Code-Bloecke in historischen
# Plaenen sollen nicht nachtraeglich umgebrochen werden.
*.md
# SearXNG-Config ist ein Template mit ${VAR}-Platzhaltern, die der
# Init-Container expandiert.
searxng/settings.yml

View File

@@ -19,6 +19,8 @@ Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://k
| **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. |
| **Gemini-Key fehlt** | Wenn `GEMINI_API_KEY` leer ist, rendert das Layout das Camera-Icon nicht, und `/new/from-photo` antwortet mit 503 (`+page.server.ts`-Gate). Graceful Degradation — kein Zombie-Button. |
| **sharp + libheif** | Im `Dockerfile`-Builder-Stage ist `vips-dev` nötig, damit `sharp` HEIC-Input (iOS) lesen kann. Runtime-Stage braucht nix zusätzlich (sharp bringt libvips prebuilt mit). |
## Dateien, die man typischerweise anfasst
@@ -26,12 +28,16 @@ Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://k
- `src/routes/+layout.svelte` — Header, mobile expand, Dropdown-Search auf Rezeptseiten
- `src/routes/recipes/[id]/+page.svelte` — Rezept-Detail mit allen Actions (Rating, Favorit, Cooked, Wunschliste, Kommentar, Umbenennen, Löschen)
- `src/routes/preview/+page.svelte` — importierte Vorschau vor dem Speichern
- `src/routes/new/from-photo/+page.svelte` — Foto-Rezept-Magie (Picker → Spinner → vorbefüllter Editor)
- `src/lib/server/ai/` — Gemini-Client, Prompt-Schema, image-preprocess, rate-limit, description-phrases
- `src/lib/components/RecipeView.svelte` / `RecipeEditor.svelte` — Lesen/Edit-Mode des Rezepts. Editor ist in Sub-Components aufgeteilt: `IngredientRow`, `StepList`, `ImageUploadBox`, `TimeDisplay` (+ shared types `recipe-editor-types.ts`)
- `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)
- `src/lib/client/*.svelte.ts` — Frontend-Stores (Search, Network, Sync-Status, Toast, Install-Prompt, Wishlist, PWA, Profile, Confirm, Search-Filter)
- `tests/e2e/remote/` — Playwright gegen `kochwas-dev.siegeln.net` (CRUD erlaubt; workers:1, serviceWorkers:block)
## Arbeitsweise (wie wir es machen)
@@ -67,7 +73,7 @@ docker compose -f docker-compose.prod.yml up --build
## Offene Themen / Stand
Siehe Session-Handoff-Dokumente unter `docs/superpowers/` und dort besonders `session-handoff-2026-04-17.md`. Die Roadmap-Phasen liegen als `docs/superpowers/plans/*.md`. Was als „Later" markiert ist, ist nicht beauftragt.
Siehe die Plan-Dateien unter `docs/superpowers/plans/*.md` für abgeschlossene Implementierungs-Phasen (v1.0 Foundations → v1.1 Offline-PWA → Post-Review-Roadmap → Search-State-Store → Editor-Split → Ingredient-Sections = v1.2). Was als „Later" markiert ist, ist nicht beauftragt.
## Auto-Memory (lokal, nicht im Repo)

View File

@@ -3,17 +3,44 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Alpine needs build tools for better-sqlite3 native module
RUN apk add --no-cache python3 make g++ libc6-compat
# Alpine needs build tools for better-sqlite3 native module.
# vips-dev provides libvips + libheif for sharp (incl. HEIC input from iOS).
RUN apk add --no-cache python3 make g++ libc6-compat vips-dev
COPY package*.json ./
RUN npm ci
# Sharp-Prebuilt-Install unter Docker-Buildx-QEMU war trotz aller Flag-
# Varianten unzuverlaessig. Finale Strategie:
# - --cpu/--os/--libc explizit setzen: sharp's offizielle Doc-Empfehlung
# fuer Cross-Platform-Docker-Builds (siehe sharp-Install-Doku),
# umgeht QEMU-Detection-Bugs.
# - --ignore-scripts + npm rebuild: loest das Parallel-Install-Race,
# bei dem sharp's install-Skript vor dem Entpacken der Prebuilt-Binary
# laeuft.
# - Explizites Nachinstallieren der Prebuilts als Sicherheit: falls (A)
# noch nicht reicht, zwingt (B) die Plattform-Pakete auf Disk.
# - node-addon-api + node-gyp als Runtime-Deps: falls am Ende doch alles
# nicht klappt und sharp from-source baut (mit dem oben installierten
# python3 + make + g++ + vips-dev).
RUN npm install --cpu=arm64 --os=linux --libc=musl \
--ignore-scripts --include=optional --no-audit --no-fund
RUN npm install --cpu=arm64 --os=linux --libc=musl \
--ignore-scripts --no-save --no-audit --no-fund \
@img/sharp-linuxmusl-arm64@0.34.5 \
@img/sharp-libvips-linuxmusl-arm64@1.2.4
RUN npm rebuild
COPY . .
RUN npm run build
# Remove dev dependencies for the runtime image
RUN npm prune --omit=dev
# Fresh-Install fuer den Runtime-Stage: nur Produktions-Deps, gleiche Strategie.
RUN rm -rf node_modules \
&& npm install --cpu=arm64 --os=linux --libc=musl \
--ignore-scripts --omit=dev --include=optional --no-audit --no-fund \
&& npm install --cpu=arm64 --os=linux --libc=musl \
--ignore-scripts --no-save --no-audit --no-fund \
@img/sharp-linuxmusl-arm64@0.34.5 \
@img/sharp-libvips-linuxmusl-arm64@1.2.4 \
&& npm rebuild
FROM node:22-alpine AS runner
WORKDIR /app

View File

@@ -11,6 +11,16 @@ services:
- IMAGE_DIR=/data/images
- SEARXNG_URL=http://searxng:8080
- NODE_ENV=production
# Im Header als kleine Versionsnummer unter dem Logo angezeigt.
- KOCHWAS_TAG=${KOCHWAS_TAG:-dev}
# Gemini (Foto-Rezept-Magie). Leer = Feature deaktiviert.
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
# adapter-node-Default ist 512 KB. Tablet- und iPad-Pro-Kameras liefern
# JPEGs/HEICs bis 15 MB. Endpoint-Limit ist 20 MB; hier 25 MB fuer den
# Multipart-Overhead.
- BODY_SIZE_LIMIT=25000000
depends_on:
- searxng
restart: unless-stopped

View File

@@ -17,8 +17,12 @@ src/
├── app.html, app.d.ts # Shell + Env-Types
├── service-worker.ts # PWA-Shell
├── lib/
│ ├── client/ # clientseitig: Profil-Store, Confirm-Dialog
│ ├── components/ # Svelte-Komponenten (RecipeView, StarRating, ConfirmDialog, ProfileSwitcher)
│ ├── client/ # reaktive Stores (Profile, Search, Wishlist, PWA, Network, Sync, Toast, Install, Confirm, API-Fetch-Wrapper)
│ ├── components/ # Svelte-Komponenten:
│ │ # - Recipe: RecipeView, RecipeEditor + Editor-Sub-Components
│ │ # (IngredientRow, StepList, ImageUploadBox, TimeDisplay, recipe-editor-types)
│ │ # - UI-Shell: ConfirmDialog, ProfileSwitcher, SyncIndicator, Toast, UpdateToast
│ │ # - Search: SearchFilter, SearchLoader, StarRating
│ ├── recipes/ # shared: Portionen-Scaler (Client UND Server)
│ ├── server/ # nur Server-Code (nie in Client-Bundle!)
│ │ ├── db/ # openDb, Migrations, DB-Singleton
@@ -54,8 +58,20 @@ src/
4. User klickt „Speichern" → `/api/recipes/import` → Importer lädt Bild (`images/downloader.ts`), SHA256-Hash-Dedup, speichert lokal, INSERT in `recipe` + `ingredient` + `step` + `recipe_tag`
5. Redirect zu `/recipes/[id]`
### Foto-Rezept-Magie (AI-Extraktion)
1. User klickt Camera-Icon im Header → `/new/from-photo` (nur gerendert wenn `GEMINI_API_KEY` gesetzt; disabled wenn offline)
2. File-Picker mit `capture="environment"` öffnet direkt die Rückkamera auf Mobile
3. Upload als `multipart/form-data``POST /api/recipes/extract-from-photo`
4. Server: MIME-Whitelist + 8 MB-Gate + Rate-Limit (10/min/IP) → `preprocessImage` (sharp, ≤1600px lange Kante, JPEG-Re-encode, Metadata-Strip) → `extractRecipeFromImage` (Gemini 2.5 Flash mit structured `responseSchema`, `temperature: 0.1`, Zod-validiert, 1× Retry bei Schema-Fehler oder 5xx) → zufällige Description aus `description-phrases.ts` (50er-Pool) → Response mit `Partial<Recipe>`
5. **Das Original-Foto wird nicht persistiert.** Der Server loggt keine Prompt/Response-Inhalte — nur Code, Dauer, Byte-Größe.
6. Client hält das Ergebnis im `PhotoUploadStore` und rendert `<RecipeEditor recipe={extracted}>`. Weil `recipe.id === null` ist, blendet der Editor den `ImageUploadBox`-Block aus und zeigt nur den Hinweis „Bild kannst du nach dem Speichern hinzufügen."
7. User editiert und klickt „Speichern" → `POST /api/recipes` (neuer Scratch-Insert-Endpoint, wrappt `insertRecipe`) → Redirect auf `/recipes/:id`
### Web-Suche
Die gesamte Live-Search-Logik ist im `SearchStore` (`src/lib/client/search.svelte.ts`) gekapselt: Debounce, Race-Guard, Pagination, Web-Fallback, Snapshot/Restore für Back-Nav. Sowohl Header-Dropdown (`+layout.svelte`) als auch Startseite (`+page.svelte`) teilen sich die Klasse mit unterschiedlicher `filterParam`-Quelle.
1. User tippt → 300 ms Debounce → `/api/recipes/search?q=...` (lokal FTS5)
2. Wenn 0 Treffer: automatisch `/api/recipes/search/web?q=...`
3. `searxng.ts` → SearXNG-API mit `site:domain OR site:domain2 ...`-Filter aus Whitelist
@@ -86,7 +102,8 @@ Gemeinsame Komponente `ConfirmDialog.svelte` wird im Root-Layout einmal gemounte
- **JSON-LD first**: Alle drei Ziel-Domains (Chefkoch, Emmi, Experimente) liefern `schema.org/Recipe` im JSON-LD. LLM-Fallback war geplant, aktuell nicht nötig.
- **SearXNG als Such-Engine**: Self-hosted, daher keine API-Keys. Das Bot-Detection-Theater wird mit gesetzten `X-Forwarded-For`-Headern aus Docker-IPs umgangen.
- **Thumbnail-Cache in SQLite**: 30 Tage TTL (per `KOCHWAS_THUMB_TTL_DAYS`). Negative Einträge (Seite ohne Bild) werden auch gecacht.
- **Svelte 5 Runes** — kein `$:` mehr, keine alten Stores außer `$app/stores`. Neue Stores via Klasse mit `$state`-Feldern.
- **Svelte 5 Runes** — kein `$:` mehr, keine alten Stores außer `$app/stores`. Neue Stores via Klasse mit `$state`-Feldern. Form-lokale Snapshots in Edit-Komponenten mit `untrack()` aus `svelte`, damit Prop-Updates nicht laufende Edits überschreiben.
- **Zutaten-Sektionen** (ab Migration 012, v1.2): `ingredient.section_heading TEXT NULL`. Ist das Feld gesetzt, startet an dieser Zeile eine neue Sektion — folgende Zutaten gehören dazu, bis die nächste Zeile wieder ein Heading hat. Kein zweites Tabellen-Modell, Ordnung bleibt `position`. Importer setzt immer `null` (schema.org/Recipe hat das Konzept nicht). Editor erlaubt Inline-Insert via `Abschnitt hinzufügen`-Button vor jeder Zeile; leeres Heading wird beim Save zu `null` normalisiert.
- **Service Worker** rein zum Shell-Cachen für Offline-First-PWA, kein intelligentes Cache-Matching (keine externe Rezept-Seiten).
## Migrations-Workflow
@@ -100,10 +117,12 @@ Bei Schema-Änderung:
## Test-Strategie
- **Unit**: `tests/unit/` — pure Funktionen (json-ld-recipe, iso8601-duration, quotes-random, smoke)
- **Unit**: `tests/unit/` — pure Funktionen + Client-Stores via jsdom (json-ld-recipe, iso8601-duration, quotes-random, scaler, ingredient-parser, SearchStore, PWA/Toast/Sync-Stores, SW-Logik).
- **Integration**: `tests/integration/` — mit `openInMemoryForTest()` fresh SQLite pro Test. Externe HTTP via `node:http`-TestServer auf Port 0 gemockt.
- **Keine Svelte-Component-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird manuell getestet)
- **Vor Commit**: `npm test && npm run check` muss grün sein.
- **E2E local**: `tests/e2e/` — Playwright gegen `npm run preview`, deckt PWA-Offline-Lifecycle ab (`offline.spec.ts`).
- **E2E remote**: `tests/e2e/remote/` — Playwright gegen `kochwas-dev.siegeln.net` via `playwright.remote.config.ts` (`workers:1`, `serviceWorkers:block`). Testet Live-API-Verhalten, inkl. destruktiver CRUD-Flows (Recipes, Kommentare, Favoriten). Run: `npm run test:e2e:remote`. Siehe `tests/e2e/remote/fixtures/` für Profile-Setup + idempotente API-Cleanup-Helper.
- **Keine Svelte-Component-Unit-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird per E2E und manuell getestet).
- **Vor Commit**: `npm test && npm run check` muss grün sein. Vor Merge zu main: zusätzlich `npm run test:e2e:remote`.
### Service Worker (PWA)
@@ -111,11 +130,11 @@ Bei Schema-Änderung:
- **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.
- **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = network-first mit 3 s-Timeout-Fallback auf Cache, 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/cache-strategy.ts``resolveStrategy({url, method})``'shell' | 'network-first' | 'images' | 'network-only'`
- `src/lib/sw/diff-manifest.ts``diffManifest(current, cached)``{toAdd, toRemove}`
Client-Stores (SSR-safe via typeof-Guards):

View File

@@ -155,7 +155,7 @@ Kochwas ist eine installierbare PWA. Erkennbar an:
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-data-v1` — Rezept-HTMLs + API-JSON (network-first, 3 s Timeout → Cache-Fallback)
- `kochwas-images-v1` — Bilder (cache-first)
- `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`)
@@ -171,3 +171,42 @@ Bei SW-Problemen Debug-Pfad:
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).
## Dev-System / Remote-E2E
`https://kochwas-dev.siegeln.net/` ist ein separates Deployment (eigener Container, eigene DB unter `/opt/docker/kochwas-dev/data/`). Zweck: E2E-Tests gegen eine prod-nahe Umgebung ohne Angst vor DB-Schäden. Die Remote-Suite (`tests/e2e/remote/`, Config `playwright.remote.config.ts`) darf dort frei CRUDen — User stellt die DB bei Bedarf per Backup wieder her.
```bash
npm run test:e2e:remote # gegen kochwas-dev
E2E_REMOTE_URL=https://... npm run test:e2e:remote # andere URL
```
Wichtige Config-Eigenschaften:
- `workers: 1` — DB-Race-Sicherheit bei CRUD-Tests.
- `serviceWorkers: 'block'` — verhindert Chromium-Crashes durch akkumulierten SW-State über 40+ Contexts.
- Fixtures unter `tests/e2e/remote/fixtures/`: `profile.ts` (Profile-Auswahl via localStorage vor Seitenladen), `api-cleanup.ts` (idempotente DELETE-Helfer für afterEach).
**Niemals gegen `kochwas.siegeln.net` (ohne `-dev`)** die destruktiven Tests laufen lassen — das ist Prod.
## Gemini / Foto-Rezept-Magie
Die Funktion „Foto → Rezept" ruft Google Gemini 2.5 Flash mit Vision auf. Im Header erscheint dann ein Kamera-Icon, das auf `/new/from-photo` führt.
**Env-Vars** (in `docker-compose.prod.yml`):
| Variable | Default | Zweck |
|---|---|---|
| `GEMINI_API_KEY` | _(leer)_ | Ohne Key ist das Feature graceful deaktiviert — Camera-Icon erscheint nicht. |
| `GEMINI_MODEL` | `gemini-2.5-flash` | Modell-Wechsel ohne Rebuild, z. B. auf `gemini-2.5-pro` bei harter Handschrift. |
| `GEMINI_TIMEOUT_MS` | `20000` | Timeout für den Vision-Call. |
**Wichtig:** Env-Änderungen greifen erst nach `docker compose up -d --force-recreate`, nicht nach `restart`.
**Privacy:** Das hochgeladene Foto geht einmal an Google Gemini und wird serverseitig nicht gespeichert. Google trainiert im Paid-Tier nicht auf API-Daten. Der Server loggt nur Status-Code, Dauer und Bildgröße — nie Prompt oder Response-Inhalt.
**Rate-Limit:** 10 Requests/Minute pro IP (in-memory, resettet beim Prozess-Restart).
**Key aus Gitea Secrets:** `GEMINI_API_KEY` als Secret in der CI-Umgebung hinterlegen; der Deploy-Schritt injiziert ihn in die `.env` des Pi-Stacks. Ablauf-Monitoring über die Google-Cloud-Konsole (≥1× pro Quartal checken).
**Build-Dep `sharp`:** Der Foto-Preprocess nutzt `sharp` (libvips). Im `Dockerfile`-Builder-Stage ist `vips-dev` enthalten, damit der npm-install auf arm64 sauber durchläuft — insbesondere für HEIC-Input von iOS-Geräten (libheif kommt via vips-dev).

View File

@@ -0,0 +1,897 @@
# Editor-Split Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Split the monolithic `RecipeEditor.svelte` (628 L) and pull one readability-oriented block out of `RecipeView.svelte` (398 L) by extracting 4 focused Svelte components: `ImageUploadBox`, `IngredientRow`, `StepList`, `TimeDisplay`. No behavior changes, just structure.
**Architecture:** Parent-owned state stays in the parent (`RecipeEditor` still owns `ingredients: DraftIng[]`, `steps: DraftStep[]`). Sub-components receive props + callbacks and render their own template + scoped CSS. Shared draft types land in `src/lib/components/recipe-editor-types.ts` so sub-components and parent agree on the shape. `RecipeView.TimeDisplay` is pure presentational with no state.
**Tech Stack:** Svelte 5 runes (`$props`, `$state`, `$derived`), TypeScript-strict, no new runtime deps.
---
## Why this is worth doing
- `RecipeEditor.svelte:42-89` (Bild-Upload) and `RecipeEditor.svelte:313-334` (Zubereitung) are each self-contained logic-islands with their own state and handlers. Extracting them caps the file a Claude can reason about in one shot.
- `IngredientRow` renders 10 lines of template with 5 ARIA labels and 6 grid-columns — a natural single-responsibility unit.
- `TimeDisplay` is pure formatting; owning it as a component lets future phases (preview, card hover) reuse it.
## What we are NOT doing
- No refactor of `RecipeView`'s tabs / servings-stepper / ingredient-display. Those work fine as-is; roadmap only names the 4 above.
- No component unit tests (kochwas has none for components; the e2e `recipe-detail.spec.ts` still covers View behavior, and edit-flow is manually smoked).
- No `<style global>` extraction. Small CSS duplication (`.add`, `.del` buttons) is accepted.
- No prop-type sharing via `<script module>` blocks. A `.ts` sibling file is simpler.
## Design Snapshot
**Shared types**`src/lib/components/recipe-editor-types.ts`:
```ts
export type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
export type DraftStep = { text: string };
```
**Component APIs (locked before implementation):**
```ts
// ImageUploadBox.svelte
type Props = {
recipeId: number;
imagePath: string | null; // initial value; component owns its own state after
onchange: (path: string | null) => void;
};
// IngredientRow.svelte
type Props = {
ing: DraftIng; // passed by reference — bind:value=ing.* works transparently
idx: number;
total: number; // for "last row? disable move-down"
onmove: (dir: -1 | 1) => void;
onremove: () => void;
};
// StepList.svelte
type Props = {
steps: DraftStep[]; // passed by reference
onadd: () => void;
onremove: (idx: number) => void;
};
// TimeDisplay.svelte
type Props = {
prepTimeMin: number | null;
cookTimeMin: number | null;
totalTimeMin: number | null;
};
```
**Render-wrapping pattern:** The parent keeps the `<section class="block"><h2>…</h2> … </section>` wrappers. Sub-components render bare content (no outer utility-class wrapper), so the parent's scoped `.block` / `h2` styling continues to apply.
---
## Task 1: Extract `ImageUploadBox`
**Files:**
- Create: `src/lib/components/ImageUploadBox.svelte`
- Modify: `src/lib/components/RecipeEditor.svelte`
- [ ] **Step 1: Create the new component**
```svelte
<!-- src/lib/components/ImageUploadBox.svelte -->
<script lang="ts">
import { ImagePlus, ImageOff } from 'lucide-svelte';
import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online';
type Props = {
recipeId: number;
imagePath: string | null;
onchange: (path: string | null) => void;
};
let { recipeId, imagePath: initial, onchange }: Props = $props();
let imagePath = $state<string | null>(initial);
let uploading = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
const imageSrc = $derived(
imagePath === null
? null
: /^https?:\/\//i.test(imagePath)
? imagePath
: `/images/${imagePath}`
);
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!requireOnline('Der Bild-Upload')) return;
uploading = true;
try {
const fd = new FormData();
fd.append('file', file);
const res = await asyncFetch(
`/api/recipes/${recipeId}/image`,
{ method: 'POST', body: fd },
'Upload fehlgeschlagen'
);
if (!res) return;
const body = await res.json();
imagePath = body.image_path;
onchange(imagePath);
} finally {
uploading = false;
}
}
async function removeImage() {
if (imagePath === null) return;
const ok = await confirmAction({
title: 'Bild entfernen?',
message: 'Das Rezept wird danach ohne Titelbild angezeigt.',
confirmLabel: 'Entfernen',
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Entfernen')) return;
uploading = true;
try {
const res = await asyncFetch(
`/api/recipes/${recipeId}/image`,
{ method: 'DELETE' },
'Entfernen fehlgeschlagen'
);
if (!res) return;
imagePath = null;
onchange(null);
} finally {
uploading = false;
}
}
</script>
<div class="image-row">
<div class="image-preview" class:empty={!imageSrc}>
{#if imageSrc}
<img src={imageSrc} alt="" />
{:else}
<span class="placeholder">Kein Bild</span>
{/if}
</div>
<div class="image-actions">
<button
class="btn"
type="button"
onclick={() => fileInput?.click()}
disabled={uploading}
>
<ImagePlus size={16} strokeWidth={2} />
<span>{imagePath ? 'Bild ersetzen' : 'Bild hochladen'}</span>
</button>
{#if imagePath}
<button class="btn ghost" type="button" onclick={removeImage} disabled={uploading}>
<ImageOff size={16} strokeWidth={2} />
<span>Entfernen</span>
</button>
{/if}
{#if uploading}
<span class="upload-status">Lade …</span>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
class="file-input"
onchange={onFileChosen}
/>
</div>
<p class="image-hint">Max. 10 MB. JPG, PNG, WebP, GIF oder AVIF.</p>
<style>
.image-row {
display: flex;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.image-preview {
width: 160px;
aspect-ratio: 16 / 10;
border-radius: 10px;
overflow: hidden;
background: #eef3ef;
border: 1px solid #e4eae7;
flex-shrink: 0;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-preview.empty {
display: grid;
place-items: center;
color: #999;
font-size: 0.85rem;
}
.image-preview .placeholder {
padding: 0 0.5rem;
text-align: center;
}
.image-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.upload-status {
color: #666;
font-size: 0.9rem;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.image-hint {
margin: 0.6rem 0 0;
color: #888;
font-size: 0.8rem;
}
.btn {
padding: 0.55rem 0.85rem;
border-radius: 10px;
border: 1px solid #cfd9d1;
background: white;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
min-height: 40px;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn.ghost {
color: #666;
}
.btn:disabled {
opacity: 0.6;
cursor: progress;
}
</style>
```
- [ ] **Step 2: Wire up `RecipeEditor.svelte`**
Remove lines 3089 (imagePath/uploading/fileInput state, imageSrc derived, onFileChosen, removeImage).
Remove these imports at the top:
```ts
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online';
```
Replace with (Task 1 needs only Plus + Trash2 + Chevrons — the image-specific imports move to the sub-component; `confirmAction`/`asyncFetch`/`requireOnline` stay for future tasks):
```ts
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-svelte';
import ImageUploadBox from '$lib/components/ImageUploadBox.svelte';
```
Remove the image-related CSS (`.image-row`, `.image-preview*`, `.image-actions`, `.image-actions .btn`, `.upload-status`, `.file-input`, `.image-hint`, `.image-block` — those live in the sub-component now).
Replace the Bild section in the template:
```svelte
<section class="block">
<h2>Bild</h2>
<ImageUploadBox
recipeId={recipe.id}
imagePath={recipe.image_path}
onchange={(p) => onimagechange?.(p)}
/>
</section>
```
- [ ] **Step 3: Run checks**
```bash
npm run check
npm test
```
Expected: 0 errors, 196/196 tests pass.
- [ ] **Step 4: Manual smoke**
```bash
npm run dev
```
Open any saved recipe → edit → upload an image → verify it shows up and `onimagechange` fires (parent's state updates). Remove the image → confirms the confirm-dialog and removes. Bail out if either flow breaks.
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/ImageUploadBox.svelte src/lib/components/RecipeEditor.svelte
git commit -m "$(cat <<'EOF'
refactor(editor): ImageUploadBox als eigenstaendige Component
Isoliert den Bild-Upload-Flow (File-Input, Preview, Entfernen-Dialog)
aus dem RecipeEditor. Parent haelt nur noch den <section>-Wrapper und
reicht recipe.id + image_path rein, kriegt Aenderungen per onchange
callback zurueck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Extract types + `IngredientRow`
**Files:**
- Create: `src/lib/components/recipe-editor-types.ts`
- Create: `src/lib/components/IngredientRow.svelte`
- Modify: `src/lib/components/RecipeEditor.svelte`
- [ ] **Step 1: Types file**
```ts
// src/lib/components/recipe-editor-types.ts
export type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
export type DraftStep = { text: string };
```
- [ ] **Step 2: IngredientRow component**
```svelte
<!-- src/lib/components/IngredientRow.svelte -->
<script lang="ts">
import { Trash2, ChevronUp, ChevronDown } from 'lucide-svelte';
import type { DraftIng } from './recipe-editor-types';
type Props = {
ing: DraftIng;
idx: number;
total: number;
onmove: (dir: -1 | 1) => void;
onremove: () => void;
};
let { ing, idx, total, onmove, onremove }: Props = $props();
</script>
<li class="ing-row">
<div class="move">
<button
class="move-btn"
type="button"
aria-label="Zutat nach oben"
disabled={idx === 0}
onclick={() => onmove(-1)}
>
<ChevronUp size={14} strokeWidth={2.5} />
</button>
<button
class="move-btn"
type="button"
aria-label="Zutat nach unten"
disabled={idx === total - 1}
onclick={() => onmove(1)}
>
<ChevronDown size={14} strokeWidth={2.5} />
</button>
</div>
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
<button class="del" type="button" aria-label="Zutat entfernen" onclick={onremove}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
<style>
.ing-row {
display: grid;
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
gap: 0.35rem;
align-items: center;
}
.move {
display: flex;
flex-direction: column;
gap: 2px;
}
.move-btn {
width: 28px;
height: 20px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 6px;
cursor: pointer;
color: #555;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.move-btn:hover:not(:disabled) {
background: #f4f8f5;
}
.move-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ing-row input {
padding: 0.5rem 0.55rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.9rem;
min-height: 38px;
font-family: inherit;
min-width: 0;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
@media (max-width: 560px) {
.ing-row {
grid-template-columns: 28px 70px 1fr 40px;
grid-template-areas:
'move qty name del'
'move unit unit del'
'note note note note';
}
.ing-row .move {
grid-area: move;
}
.ing-row .qty {
grid-area: qty;
}
.ing-row .unit {
grid-area: unit;
}
.ing-row .name {
grid-area: name;
}
.ing-row .note {
grid-area: note;
}
.ing-row .del {
grid-area: del;
}
}
</style>
```
- [ ] **Step 3: Wire up `RecipeEditor.svelte`**
Replace the local `DraftIng` / `DraftStep` type declarations (lines 100106) with:
```ts
import type { DraftIng, DraftStep } from '$lib/components/recipe-editor-types';
import IngredientRow from '$lib/components/IngredientRow.svelte';
```
In the template, swap the `<li class="ing-row">` block for:
```svelte
{#each ingredients as ing, idx (idx)}
<IngredientRow
{ing}
{idx}
total={ingredients.length}
onmove={(dir) => moveIngredient(idx, dir)}
onremove={() => removeIngredient(idx)}
/>
{/each}
```
Remove the CSS for `.ing-row`, `.move`, `.move-btn`, `.ing-row input`, `.del`, and the `@media (max-width: 560px)` block — all now live in `IngredientRow.svelte`.
Remove the unused imports `ChevronUp`, `ChevronDown`, `Trash2` from RecipeEditor (they moved to the sub-component, but wait — `Trash2` is also used for step-remove. Keep `Trash2`, remove the two Chevrons).
- [ ] **Step 4: Run checks**
```bash
npm run check
npm test
```
- [ ] **Step 5: Manual smoke**
Open any recipe in edit mode. Add an ingredient, type into all 4 fields, reorder up/down, remove one. Verify save persists the ordering.
- [ ] **Step 6: Commit**
```bash
git add src/lib/components/recipe-editor-types.ts src/lib/components/IngredientRow.svelte src/lib/components/RecipeEditor.svelte
git commit -m "$(cat <<'EOF'
refactor(editor): IngredientRow + shared types
IngredientRow rendert eine einzelne editierbare Zutat-Zeile. DraftIng
und DraftStep sind jetzt in recipe-editor-types.ts, damit Parent und
Sub-Components auf dieselbe Form referenzieren.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Extract `StepList`
**Files:**
- Create: `src/lib/components/StepList.svelte`
- Modify: `src/lib/components/RecipeEditor.svelte`
- [ ] **Step 1: StepList component**
```svelte
<!-- src/lib/components/StepList.svelte -->
<script lang="ts">
import { Plus, Trash2 } from 'lucide-svelte';
import type { DraftStep } from './recipe-editor-types';
type Props = {
steps: DraftStep[];
onadd: () => void;
onremove: (idx: number) => void;
};
let { steps, onadd, onremove }: Props = $props();
</script>
<ol class="step-list">
{#each steps as step, idx (idx)}
<li class="step-row">
<span class="num">{idx + 1}</span>
<textarea
bind:value={step.text}
rows="3"
placeholder="Schritt beschreiben …"
></textarea>
<button class="del" type="button" aria-label="Schritt entfernen" onclick={() => onremove(idx)}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
{/each}
</ol>
<button class="add" type="button" onclick={onadd}>
<Plus size={16} strokeWidth={2} />
<span>Schritt hinzufügen</span>
</button>
<style>
.step-list {
list-style: none;
padding: 0;
margin: 0 0 0.6rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.step-row {
display: grid;
grid-template-columns: 32px 1fr 40px;
gap: 0.5rem;
align-items: start;
}
.num {
width: 32px;
height: 32px;
background: #2b6a3d;
color: white;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 600;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.step-row textarea {
padding: 0.55rem 0.7rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
min-height: 70px;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
.add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 0.9rem;
border: 1px dashed #cfd9d1;
background: white;
color: #2b6a3d;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.add:hover {
background: #f4f8f5;
}
</style>
```
- [ ] **Step 2: Wire up `RecipeEditor.svelte`**
Add import:
```ts
import StepList from '$lib/components/StepList.svelte';
```
Replace the entire Zubereitung `<section class="block">` template block (starting `<section class="block">` with `<h2>Zubereitung</h2>` through the add-step button):
```svelte
<section class="block">
<h2>Zubereitung</h2>
<StepList {steps} onadd={addStep} onremove={removeStep} />
</section>
```
**CSS audit — what stays and what goes in the parent:**
Parent's template after Tasks 13 still contains:
- `<section class="block"><h2>Bild</h2><ImageUploadBox .../></section>` — no `.block` inner styles needed beyond what's in parent.
- `<div class="meta">` — still here. Keep `.meta`, `.field`, `.row`, `.small`, `.lbl`.
- `<section class="block"><h2>Zutaten</h2><ul class="ing-list">{#each ..}<IngredientRow/>{/each}</ul><button class="add">...</button></section>` — still uses `.ing-list` and `.add`.
- `<section class="block"><h2>Zubereitung</h2><StepList/></section>` — no inner CSS.
- `<div class="foot"><button class="btn ghost">...</button><button class="btn primary">...</button></div>` — keeps `.foot`, `.btn`, `.btn.ghost`, `.btn.primary`, `.btn:disabled`.
So parent CSS after Task 3 keeps: `.editor`, `.meta`, `.field`, `.lbl`, `.row`, `.small`, `.block`, `.block h2`, `.ing-list` (the `<ul>` wrapper), `.add` (for "Zutat hinzufügen"), `.foot`, `.btn` and variants.
Drop from parent CSS in Task 3: `.step-list`, `.step-row`, `.num`, `.step-row textarea`, `.del`.
- [ ] **Step 3: Run checks**
```bash
npm run check
npm test
```
- [ ] **Step 4: Manual smoke**
Open any recipe → edit → add a step, type, remove, save. Verify steps persist with correct ordering.
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/StepList.svelte src/lib/components/RecipeEditor.svelte
git commit -m "$(cat <<'EOF'
refactor(editor): StepList als eigenstaendige Component
Zubereitungs-Liste mit Add + Remove als Sub-Component. Parent steuert
nur noch den Wrapper und reicht steps + die zwei Callbacks rein.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Extract `TimeDisplay` (RecipeView)
**Files:**
- Create: `src/lib/components/TimeDisplay.svelte`
- Modify: `src/lib/components/RecipeView.svelte`
- [ ] **Step 1: TimeDisplay component**
```svelte
<!-- src/lib/components/TimeDisplay.svelte -->
<script lang="ts">
type Props = {
prepTimeMin: number | null;
cookTimeMin: number | null;
totalTimeMin: number | null;
};
let { prepTimeMin, cookTimeMin, totalTimeMin }: Props = $props();
const summary = $derived.by(() => {
const parts: string[] = [];
if (prepTimeMin) parts.push(`Vorb. ${prepTimeMin} min`);
if (cookTimeMin) parts.push(`Kochen ${cookTimeMin} min`);
if (!prepTimeMin && !cookTimeMin && totalTimeMin)
parts.push(`Gesamt ${totalTimeMin} min`);
return parts.join(' · ');
});
</script>
{#if summary}
<p class="times">{summary}</p>
{/if}
<style>
.times {
margin: 0 0 0.25rem;
color: #666;
font-size: 0.9rem;
}
</style>
```
- [ ] **Step 2: Wire up `RecipeView.svelte`**
Add import:
```ts
import TimeDisplay from '$lib/components/TimeDisplay.svelte';
```
Remove the local `timeSummary()` function (lines 4552).
Replace the `{#if timeSummary()}<p class="times">...</p>{/if}` block in the template with:
```svelte
<TimeDisplay
prepTimeMin={recipe.prep_time_min}
cookTimeMin={recipe.cook_time_min}
totalTimeMin={recipe.total_time_min}
/>
```
Remove the `.times` CSS from RecipeView (it's in the sub-component now).
- [ ] **Step 3: Run checks**
```bash
npm run check
npm test
```
- [ ] **Step 4: Manual smoke**
Open any recipe → verify the time line still shows the same content (Vorb. / Kochen / Gesamt).
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/TimeDisplay.svelte src/lib/components/RecipeView.svelte
git commit -m "$(cat <<'EOF'
refactor(view): TimeDisplay als eigenstaendige Component
timeSummary-Formatierung in eine wiederverwendbare Component
gezogen. RecipeView liefert nur noch die drei Werte — zukuenftige
Call-Sites (Preview, Hover-Cards) koennen dieselbe Logik reusen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Self-review + push
- [ ] **Step 1: Line-count audit**
```bash
wc -l src/lib/components/RecipeEditor.svelte src/lib/components/RecipeView.svelte src/lib/components/ImageUploadBox.svelte src/lib/components/IngredientRow.svelte src/lib/components/StepList.svelte src/lib/components/TimeDisplay.svelte
```
Expected shape (approximate, ±10%):
- `RecipeEditor.svelte`: 628 → ~330370
- `RecipeView.svelte`: 398 → ~380
- `ImageUploadBox.svelte`: ~160
- `IngredientRow.svelte`: ~110
- `StepList.svelte`: ~100
- `TimeDisplay.svelte`: ~30
- [ ] **Step 2: Full test + typecheck**
```bash
npm test
npm run check
```
Both green.
- [ ] **Step 3: Git log review**
```bash
git log --oneline main..HEAD
```
Expected 4 commits:
1. `refactor(editor): ImageUploadBox als eigenstaendige Component`
2. `refactor(editor): IngredientRow + shared types`
3. `refactor(editor): StepList als eigenstaendige Component`
4. `refactor(view): TimeDisplay als eigenstaendige Component`
- [ ] **Step 4: Remote E2E after push**
```bash
git push -u origin editor-split
```
CI builds branch-tagged image. After deploy to `kochwas-dev.siegeln.net`:
```bash
npm run test:e2e:remote
```
Expected: 40/42 green (same as Search-State-Store baseline). `recipe-detail.spec.ts` (6 tests) specifically exercises the View side — must be clean.
Manual UAT pass on `https://kochwas-dev.siegeln.net/`:
- Edit a recipe → upload + remove image.
- Add / reorder / remove an ingredient → save → verify persistence on reload.
- Add / remove a step → save → verify.
- Check time-summary rendering on any recipe with prep/cook/total times set.
- [ ] **Step 5: Merge to main**
Once UAT is clean:
```bash
git checkout main
git merge --no-ff editor-split
git push origin main
```
---
## Risk Notes
- **Prop-reference mutability.** `IngredientRow` and `StepList` receive `ing` / `steps` by reference and use `bind:value` on their own `<input>` / `<textarea>` elements. Svelte 5 handles this correctly — writes propagate to the parent's `$state` array. Verified pattern with existing `searchFilterStore` usage and similar bind-through-prop in older Svelte 5 components in this codebase.
- **Confirm-dialog scope.** `ImageUploadBox` imports `confirmAction` directly rather than using a prop-callback. Consistent with the rest of the codebase (`confirmAction` is a global).
- **Scoped CSS duplication.** `.del` and `.add` button styles exist in multiple sub-components. Accepted — the alternative (global button classes) is out of scope for this phase.
- **No component unit tests.** Risk: a structural mistake (bad prop passing, missing callback wiring) wouldn't be caught by logic-layer tests. Mitigation: manual smoke test + `npm run check` type-safety + existing e2e coverage on RecipeView side.
## Deferred — NOT in this plan
- **Component unit tests with `@testing-library/svelte`:** Would add Vitest+browser setup. Worth doing in a separate phase once the project acquires a second component-refactor candidate.
- **Edit-flow E2E spec:** `tests/e2e/remote/recipe-edit.spec.ts` would cover the editor end-to-end. Valuable, but out of scope here — this phase is structural extraction, not test coverage expansion.
- **Extract `RecipeHero` / `ServingsStepper` / `TabSwitcher` from RecipeView:** Not on the roadmap. Add to a future phase if RecipeView grows further.

View File

@@ -0,0 +1,634 @@
# Ingredient Sections Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Zutaten können im Editor in benannte Sektionen (z. B. „Für den Teig", „Für die Füllung") gruppiert werden; in der View werden die Sektionen als Überschriften über den zugehörigen Zutatenblöcken gerendert.
**Architecture:** Eine neue nullable Spalte `section_heading` auf `ingredient`. Ist sie gesetzt, startet an dieser Zeile eine neue Sektion — alle folgenden Zutaten gehören dazu bis zur nächsten Zeile mit gesetzter `section_heading`. Ordnung bleibt `position`. Keine neue Tabelle, keine zweite Ordnungsachse, Scaler/FTS/Importer bleiben unverändert im Verhalten (nur Type-Passthrough). Inline-Button „Abschnitt hinzufügen" erscheint im Editor vor jeder Zutatenzeile und am Listenende.
**Tech Stack:** better-sqlite3 Migration, TypeScript-strict, Svelte 5 runes, vitest.
**Scope-Entscheidungen (vom User bestätigt):**
- Sektionen **nur für Zutaten**, nicht für Zubereitungsschritte.
- „Abschnitt hinzufügen"-Button inline vor jeder Zeile (plus einer am Listenende).
- Keine Import-Extraction — JSON-LD hat keine Sektionen, Emmikochteinfach rendert sie nur im HTML. Später via HTML-Parse möglich, aber out-of-scope.
---
### Task 1: Migration + Type-Erweiterung + parseIngredient-Sites
**Files:**
- Create: `src/lib/server/db/migrations/012_ingredient_section.sql`
- Modify: `src/lib/types.ts` (Ingredient type)
- Modify: `src/lib/server/parsers/ingredient.ts` (3 return sites)
- Test: `tests/unit/ingredient.test.ts` (bereits existierend, muss grün bleiben)
**Warum zusammen:** Nach der Type-Änderung schlägt `svelte-check` überall fehl, wo ein `Ingredient`-Literal gebaut wird. `parseIngredient` hat 3 solcher Stellen und ist vom selben Commit abhängig, sonst wird der Build rot.
- [ ] **Step 1: Migration schreiben**
Create `src/lib/server/db/migrations/012_ingredient_section.sql`:
```sql
-- Nullable — alte Zeilen behalten NULL, neue dürfen eine Überschrift haben.
-- Rendering-Regel: Ist section_heading gesetzt (nicht NULL und nicht leer),
-- startet an dieser Zeile eine neue Sektion mit diesem Titel.
ALTER TABLE ingredient ADD COLUMN section_heading TEXT;
```
- [ ] **Step 2: Ingredient-Type erweitern**
Modify `src/lib/types.ts`:
```ts
export type Ingredient = {
position: number;
quantity: number | null;
unit: string | null;
name: string;
note: string | null;
raw_text: string;
section_heading: string | null;
};
```
- [ ] **Step 3: parseIngredient-Return-Sites aktualisieren**
Modify `src/lib/server/parsers/ingredient.ts`:
Alle drei `return { position, ... raw_text: rawText };`-Literale (Zeilen 108, 115, 119) bekommen `section_heading: null` am Ende. Beispiel für Zeile 108:
```ts
return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null };
```
Analog für Zeilen 115 und 119.
- [ ] **Step 4: Bestehende Unit-Tests grün**
Run: `npm run test -- ingredient.test.ts`
Expected: PASS (Tests prüfen nur vorhandene Felder, neues Feld stört nicht).
- [ ] **Step 5: Svelte-Check muss noch rot sein**
Run: `npm run check`
Expected: FAIL mit Fehlern in `repository.ts` (Select-Statement ohne `section_heading`). Das ist erwartet — wird in Task 2 behoben. Nicht hier fixen.
- [ ] **Step 6: Commit**
```bash
git add src/lib/types.ts src/lib/server/db/migrations/012_ingredient_section.sql src/lib/server/parsers/ingredient.ts
git commit -m "feat(schema): ingredient.section_heading (Migration 012 + Type)"
```
---
### Task 2: Repository-Layer Persistenz
**Files:**
- Modify: `src/lib/server/recipes/repository.ts` (insertRecipe, replaceIngredients, getRecipeById)
- Test: `tests/integration/recipe-repository.test.ts`
**Warum jetzt:** Nach Task 1 ist der Type-Vertrag aufgemacht. Die DB muss das Feld lesen und schreiben, sonst gehen Sektionen beim Save/Load verloren.
- [ ] **Step 1: Failing test für Roundtrip**
Add to `tests/integration/recipe-repository.test.ts` inside `describe('recipe repository', ...)`:
```ts
it('persistiert section_heading und gibt es beim Laden zurück', () => {
const db = openInMemoryForTest();
const recipe = baseRecipe({
title: 'Torte',
ingredients: [
{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Für den Teig' },
{ position: 2, quantity: 100, unit: 'g', name: 'Zucker', note: null, raw_text: '100 g Zucker', section_heading: null },
{ position: 3, quantity: 300, unit: 'g', name: 'Beeren', note: null, raw_text: '300 g Beeren', section_heading: 'Für die Füllung' }
]
});
const id = insertRecipe(db, recipe);
const loaded = getRecipeById(db, id);
expect(loaded!.ingredients[0].section_heading).toBe('Für den Teig');
expect(loaded!.ingredients[1].section_heading).toBeNull();
expect(loaded!.ingredients[2].section_heading).toBe('Für die Füllung');
});
it('replaceIngredients persistiert section_heading', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, baseRecipe({ title: 'X' }));
replaceIngredients(db, id, [
{ position: 1, quantity: null, unit: null, name: 'A', note: null, raw_text: 'A', section_heading: 'Kopf' }
]);
const loaded = getRecipeById(db, id);
expect(loaded!.ingredients[0].section_heading).toBe('Kopf');
});
```
- [ ] **Step 2: Test laufen — muss fehlschlagen**
Run: `npm run test -- recipe-repository.test.ts`
Expected: FAIL — `section_heading` kommt als `undefined` zurück, weil SQL-SELECT es nicht holt.
- [ ] **Step 3: INSERT-Statements erweitern**
Modify `src/lib/server/recipes/repository.ts`:
In `insertRecipe` (line ~66): Spalte + Parameter anhängen.
```ts
const insIng = db.prepare(
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const ing of recipe.ingredients) {
insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
}
```
In `replaceIngredients` (line ~217): gleiche Änderung.
```ts
const ins = db.prepare(
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const ing of ingredients) {
ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
}
```
- [ ] **Step 4: SELECT-Statement erweitern**
In `getRecipeById` (line ~105):
```ts
const ingredients = db
.prepare(
`SELECT position, quantity, unit, name, note, raw_text, section_heading
FROM ingredient WHERE recipe_id = ? ORDER BY position`
)
.all(id) as Ingredient[];
```
- [ ] **Step 5: Tests grün**
Run: `npm run test -- recipe-repository.test.ts`
Expected: PASS.
- [ ] **Step 6: Volle Suite + svelte-check**
Run: `npm test && npm run check`
Expected: Beides PASS. `svelte-check` ist jetzt auf Repo-Ebene typ-clean; View/Editor noch nicht berührt, deren Nutzung von `Ingredient` bleibt (Feld darf fehlen, weil der Type optional wirkt? — Nein, es ist `string | null`, also **pflicht**. Falls `check` rot wird, liegt es an Importer/Scaler-Aufrufern, die `Ingredient`-Literale bauen. Das ist dann Task 3.)
- [ ] **Step 7: Commit**
```bash
git add src/lib/server/recipes/repository.ts tests/integration/recipe-repository.test.ts
git commit -m "feat(db): section_heading roundtrip in recipe-repository"
```
---
### Task 3: Importer-Passthrough + Scaler-Test
**Files:**
- Modify: `src/lib/recipes/scaler.ts` (nur falls Test rot — siehe unten)
- Test: `tests/unit/scaler.test.ts`
- Test: evtl. `tests/integration/importer.test.ts`
**Warum:** parseIngredient setzt `section_heading: null` (Task 1). Das reicht für den Importer — keine JSON-LD-Extraction. Aber der Scaler ruft `.map((i) => ({ ...i, quantity: ... }))` auf; das Spread erhält `section_heading` automatisch. Wir fügen nur einen Regressions-Test hinzu, dass das stimmt.
- [ ] **Step 1: Scaler-Regressions-Test**
Add to `tests/unit/scaler.test.ts`:
```ts
it('preserves section_heading through scaling', () => {
const input: Ingredient[] = [
{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Teig' },
{ position: 2, quantity: null, unit: null, name: 'Ei', note: null, raw_text: 'Ei', section_heading: null }
];
const scaled = scaleIngredients(input, 2);
expect(scaled[0].section_heading).toBe('Teig');
expect(scaled[1].section_heading).toBeNull();
expect(scaled[0].quantity).toBe(400);
});
```
- [ ] **Step 2: Test laufen**
Run: `npm run test -- scaler.test.ts`
Expected: PASS (weil `...i` das Feld durchreicht).
Falls FAIL: In `src/lib/recipes/scaler.ts` das `.map` prüfen — es sollte `...i` spreaden und nur `quantity` überschreiben. Bei Abweichung angleichen.
- [ ] **Step 3: Importer-Roundtrip-Test (Bolognese-Fixture)**
Prüfen, dass Importer für Emmi-Fixture `section_heading: null` auf allen Zutaten liefert. Der existierende `importer.test.ts` sollte automatisch grün bleiben (parseIngredient setzt das Feld auf null), aber wir schauen kurz nach:
Run: `npm run test -- importer.test.ts`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add tests/unit/scaler.test.ts
git commit -m "test(scaler): section_heading ueberlebt Skalierung"
```
---
### Task 4: IngredientRow — Heading-Anzeige + Inline Insert-Button
**Files:**
- Modify: `src/lib/components/recipe-editor-types.ts`
- Modify: `src/lib/components/IngredientRow.svelte`
- Test: neue Svelte-Component-Tests via vitest-browser — **ausgenommen**: wir haben keine Svelte-Component-Unit-Tests im Repo. Stattdessen decken E2E + manuelle Verifikation ab. Das ist konsistent mit der bestehenden Praxis.
**Verhalten:**
- `DraftIng` bekommt `section_heading: string | null` (immer gesetzt, aber nullable).
- Hat eine Zeile `section_heading` als String (auch leer), wird oberhalb der Row ein `<input>` für den Titel gerendert plus ein kleiner „Sektion entfernen"-Button.
- Hat eine Zeile `section_heading === null`, wird ein dezenter `<button class="add-section">Abschnitt hinzufügen</button>` **über** der Row gerendert.
- IngredientRow bekommt Callbacks `onaddSection`, `onremoveSection` — Parent verwaltet das Array.
- [ ] **Step 1: DraftIng-Typ erweitern**
Modify `src/lib/components/recipe-editor-types.ts`:
```ts
export type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
section_heading: string | null;
};
export type DraftStep = { text: string };
```
- [ ] **Step 2: IngredientRow erweitern — Props**
Modify `src/lib/components/IngredientRow.svelte` Script-Block:
```svelte
<script lang="ts">
import { Trash2, ChevronUp, ChevronDown, Plus, X } from 'lucide-svelte';
import type { DraftIng } from './recipe-editor-types';
type Props = {
ing: DraftIng;
idx: number;
total: number;
onmove: (dir: -1 | 1) => void;
onremove: () => void;
onaddSection: () => void;
onremoveSection: () => void;
};
let { ing, idx, total, onmove, onremove, onaddSection, onremoveSection }: Props = $props();
</script>
```
- [ ] **Step 3: IngredientRow-Template — Section-Block + Add-Button**
Replace the existing `<li class="ing-row">…</li>` with:
```svelte
{#if ing.section_heading === null}
<li class="section-insert">
<button type="button" class="add-section" onclick={onaddSection}>
<Plus size={12} strokeWidth={2.5} />
<span>Abschnitt hinzufügen</span>
</button>
</li>
{:else}
<li class="section-heading-row">
<input
class="section-heading"
type="text"
bind:value={ing.section_heading}
placeholder="Sektion, z. B. Für den Teig""
aria-label="Sektionsüberschrift"
/>
<button
type="button"
class="section-remove"
aria-label="Sektion entfernen"
onclick={onremoveSection}
>
<X size={14} strokeWidth={2.5} />
</button>
</li>
{/if}
<li class="ing-row">
<div class="move">
<!-- unchanged -->
<button class="move-btn" type="button" aria-label="Zutat nach oben" disabled={idx === 0} onclick={() => onmove(-1)}>
<ChevronUp size={14} strokeWidth={2.5} />
</button>
<button class="move-btn" type="button" aria-label="Zutat nach unten" disabled={idx === total - 1} onclick={() => onmove(1)}>
<ChevronDown size={14} strokeWidth={2.5} />
</button>
</div>
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
<button class="del" type="button" aria-label="Zutat entfernen" onclick={onremove}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
```
**Hinweis:** Wir rendern pro Row zwei `<li>`: optional einen Sektions-Block (Insert-Button ODER Heading-Input), plus die bestehende Zutaten-Row. Das passt in die `<ul class="ing-list">` des Parents — semantisch unsauber (nicht-Zutat-`<li>` in Zutatenliste), aber praktikabel; alternativ könnte IngredientRow auf `<div>` umgestellt werden, das wäre aber ein Parent-Umbau. Wir bleiben bei `<li>` und geben dem Section-`<li>` `list-style: none` via CSS.
- [ ] **Step 4: Styles für Section-UI**
Add to `<style>`-Block in `IngredientRow.svelte`:
```css
.section-insert {
display: flex;
justify-content: center;
list-style: none;
margin: -0.2rem 0 0.1rem;
opacity: 0;
transition: opacity 0.15s;
}
.ing-list:hover .section-insert,
.section-insert:focus-within {
opacity: 1;
}
.add-section {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.55rem;
border: 1px dashed #cfd9d1;
background: white;
color: #2b6a3d;
border-radius: 999px;
cursor: pointer;
font-size: 0.75rem;
font-family: inherit;
}
.add-section:hover {
background: #f4f8f5;
}
.section-heading-row {
display: grid;
grid-template-columns: 1fr 32px;
gap: 0.35rem;
list-style: none;
margin-top: 0.4rem;
}
.section-heading {
padding: 0.45rem 0.7rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
color: #2b6a3d;
font-family: inherit;
background: #f4f8f5;
}
.section-remove {
width: 32px;
height: 38px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 8px;
color: #666;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.section-remove:hover {
background: #fdf3f3;
border-color: #f1b4b4;
color: #c53030;
}
```
**Begründung `opacity: 0` + Hover:** Der Insert-Button erscheint vor **jeder** Zeile — das ist visuelles Rauschen auf statischem Zustand. Fade-in-on-hover hält die Zutatenliste lesbar und macht den Button auf Mouse-Interaktion trotzdem sichtbar. Auf Touch-Geräten ist `:hover` ggf. sticky — das ist OK, weil auf Mobile die Zutatenliste ohnehin explorativ bedient wird. `:focus-within` deckt Keyboard-Navigation ab.
- [ ] **Step 5: svelte-check**
Run: `npm run check`
Expected: FAIL — `RecipeEditor.svelte` gibt die neuen Callbacks `onaddSection` / `onremoveSection` noch nicht rein, und `DraftIng`-Literale im Editor haben noch kein `section_heading`. Wird in Task 5 behoben.
- [ ] **Step 6: Commit**
```bash
git add src/lib/components/IngredientRow.svelte src/lib/components/recipe-editor-types.ts
git commit -m "feat(editor): Sektionsueberschriften in IngredientRow + Insert-Button"
```
---
### Task 5: RecipeEditor — State, Handler, Save-Patch
**Files:**
- Modify: `src/lib/components/RecipeEditor.svelte`
- [ ] **Step 1: DraftIng-Seeding erweitern**
In `RecipeEditor.svelte` Script-Block, `ingredients`-State (line ~40):
```ts
let ingredients = $state<DraftIng[]>(
untrack(() =>
recipe.ingredients.map((i) => ({
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
unit: i.unit ?? '',
name: i.name,
note: i.note ?? '',
section_heading: i.section_heading
}))
)
);
```
- [ ] **Step 2: addIngredient aktualisieren**
```ts
function addIngredient() {
ingredients = [...ingredients, { qty: '', unit: '', name: '', note: '', section_heading: null }];
}
```
- [ ] **Step 3: Section-Handler einfügen**
```ts
function addSection(idx: number) {
const next = [...ingredients];
next[idx] = { ...next[idx], section_heading: '' };
ingredients = next;
}
function removeSection(idx: number) {
const next = [...ingredients];
next[idx] = { ...next[idx], section_heading: null };
ingredients = next;
}
```
- [ ] **Step 4: save()-Patch erweitern**
In `save()` (line ~86), das `cleanedIngredients`-Mapping:
```ts
const cleanedIngredients: Ingredient[] = ingredients
.filter((i) => i.name.trim())
.map((i, idx) => {
const qty = parseQty(i.qty);
const unit = i.unit.trim() || null;
const name = i.name.trim();
const note = i.note.trim() || null;
const rawParts: string[] = [];
if (qty !== null) rawParts.push(String(qty).replace('.', ','));
if (unit) rawParts.push(unit);
rawParts.push(name);
const heading = i.section_heading === null ? null : (i.section_heading.trim() || null);
return {
position: idx + 1,
quantity: qty,
unit,
name,
note,
raw_text: rawParts.join(' '),
section_heading: heading
};
});
```
**Regel:** Eine leere Sektion (`section_heading === ''` nach Trim) wird beim Speichern zu `null`. Begründung: User tippt „Abschnitt hinzufügen" und lässt das Feld leer → keine unbenannte Sektion in der View. Nur Zeilen mit echtem Titel werden als Sektionsanker persistiert.
- [ ] **Step 5: IngredientRow-Callbacks verdrahten**
In `RecipeEditor.svelte` Template (line ~170):
```svelte
{#each ingredients as ing, idx (idx)}
<IngredientRow
{ing}
{idx}
total={ingredients.length}
onmove={(dir) => moveIngredient(idx, dir)}
onremove={() => removeIngredient(idx)}
onaddSection={() => addSection(idx)}
onremoveSection={() => removeSection(idx)}
/>
{/each}
```
- [ ] **Step 6: svelte-check + Tests**
Run: `npm run check && npm test`
Expected: Beides grün.
- [ ] **Step 7: Commit**
```bash
git add src/lib/components/RecipeEditor.svelte
git commit -m "feat(editor): Sektionen-Handler + save-Patch mit section_heading"
```
---
### Task 6: RecipeView — Sektions-Überschriften rendern
**Files:**
- Modify: `src/lib/components/RecipeView.svelte`
- [ ] **Step 1: Zutatenliste umbauen**
In `RecipeView.svelte` (line ~128), den `<ul class="ing-list">`-Block:
```svelte
<ul class="ing-list">
{#each scaled as ing, i (i)}
{#if ing.section_heading && ing.section_heading.trim()}
<li class="section-heading">{ing.section_heading}</li>
{/if}
<li>
{#if ing.quantity !== null || ing.unit}
<span class="qty">
{formatQty(ing.quantity)}
{#if ing.unit}
{' '}{ing.unit}
{/if}
</span>
{/if}
<span class="name">
{ing.name}
{#if ing.note}<span class="note"> ({ing.note})</span>{/if}
</span>
</li>
{/each}
</ul>
```
**Hinweis:** `<li class="section-heading">` statt `<h3>` — wir sind in einer `<ul>` und dürfen dort nur `<li>` direkt verschachteln. Semantisch ist das OK, Screenreader lesen die Heading-Klasse nicht als Landmark, aber sie liest den Text als normales Listen-Item; für ein Rezept ist das akzeptabel. Alternativ: `<ul>` in mehrere `<section>`s aufsplitten — deutlich komplexer bei gleicher visueller Wirkung; verschoben, bis jemand klagt.
- [ ] **Step 2: Style für .section-heading**
Add to `<style>`-Block in `RecipeView.svelte`:
```css
.ing-list .section-heading {
list-style: none;
font-weight: 600;
color: #2b6a3d;
font-size: 1rem;
margin-top: 0.9rem;
margin-bottom: 0.2rem;
padding: 0.15rem 0;
border-bottom: 1px solid #e4eae7;
}
.ing-list .section-heading:first-child {
margin-top: 0;
}
```
- [ ] **Step 3: Tests + Check**
Run: `npm run check && npm test`
Expected: Beides grün.
- [ ] **Step 4: Dev-Build-Smoke-Test**
Run: `npm run build && npm run preview`
Manuell: Rezept öffnen, editieren, Sektion „Teig" auf Zeile 1 setzen und „Füllung" auf Zeile 3, speichern. Wechsel zu View → beide Überschriften sichtbar, Skalierung ändert nur Mengen. Screenshot ist nice-to-have, nicht Pflicht.
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/RecipeView.svelte
git commit -m "feat(view): Zutaten-Sektionen als Ueberschriften rendern"
```
---
### Task 7: Ship
- [ ] **Step 1: Finale Testsuite**
Run: `npm run check && npm test`
Expected: Beides grün.
- [ ] **Step 2: Push**
```bash
git push -u origin feature/ingredient-sections
```
- [ ] **Step 3: Auf Deploy warten (CI-Image-Build, Pi-Pull)**
User wird manuell signalisieren, wenn deployed.
- [ ] **Step 4: Nach Deploy — Playwright Remote-Smoke**
Run: `npm run test:e2e:remote`
Expected: 42/42 green (unchanged suite, wir haben keine Recipe-Edit-E2E-Tests hinzugefügt).
- [ ] **Step 5: Merge zu main**
Falls E2E grün:
```bash
git checkout main
git merge --no-ff feature/ingredient-sections -m "Merge ingredient-sections — Zutaten-Gruppierung via section_heading"
git push
```
---
## Self-Review-Notiz
- Spec-Coverage: alle drei User-Anforderungen abgedeckt (Inline-Button vor jeder Zeile → Task 4, nur Zutaten → keine Step-Änderungen, Edit-Mode-only → Importer unverändert).
- Type-Konsistenz: `section_heading: string | null` überall einheitlich (Ingredient, DraftIng, Save-Patch).
- Keine Placeholder — alle SQL-/Code-Snippets ausgeschrieben.
- Migrations-Reihenfolge: `012_` nach `011_clear_favicon_for_rerun.sql`.
- FTS-Impact: `section_heading` taucht nicht im FTS-Trigger auf (`001_init.sql` nutzt `name`, `description`, `ingredients_concat`, `tags_concat`). Das ist bewusst so — Sektionstitel sind Organisationshilfen, kein Suchinhalt. User suchen nach „Mehl", nicht nach „Für den Teig".

View File

@@ -0,0 +1,971 @@
# Search-State-Store Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extract the duplicated live-search state machine from `src/routes/+page.svelte` and `src/routes/+layout.svelte` into a single reusable `SearchStore` class in `src/lib/client/search.svelte.ts`, so both the home search and the header dropdown drive their UI from the same logic.
**Architecture:** Factory-class store (one instance per consumer, like `new SearchStore()` — not a shared singleton). Holds all `$state` fields currently inlined in the Svelte components (query, hits, webHits, searching flags, error, pagination state), plus imperative methods (`runDebounced`, `loadMore`, `reSearch`, `reset`, `captureSnapshot`, `restoreSnapshot`). Consumers keep UI-specific concerns (URL sync, dropdown open/close, snapshot hookup) in their component — the store owns only fetch/pagination/debounce.
**Tech Stack:** Svelte 5 runes (`$state` in class fields), TypeScript-strict, Vitest + jsdom, fetch injection for tests.
---
## Design Snapshot
**API surface (locked before implementation):**
```ts
// src/lib/client/search.svelte.ts
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
export type SearchSnapshot = {
query: string;
hits: SearchHit[];
webHits: WebHit[];
searchedFor: string | null;
webError: string | null;
localExhausted: boolean;
webPageno: number;
webExhausted: boolean;
};
export type SearchStoreOptions = {
pageSize?: number; // default 30
debounceMs?: number; // default 300
filterDebounceMs?: number; // default 150 (shorter for filter-change re-search)
minQueryLength?: number; // default 4 (query.trim().length > 3)
filterParam?: () => string; // e.g. () => searchFilterStore.queryParam → "foo,bar" or ""
fetchImpl?: typeof fetch; // injected for tests
};
export class SearchStore {
query = $state('');
hits = $state<SearchHit[]>([]);
webHits = $state<WebHit[]>([]);
searching = $state(false);
webSearching = $state(false);
webError = $state<string | null>(null);
searchedFor = $state<string | null>(null);
localExhausted = $state(false);
webPageno = $state(0);
webExhausted = $state(false);
loadingMore = $state(false);
constructor(opts?: SearchStoreOptions);
/** Call from `$effect(() => { store.query; store.runDebounced(); })`. Handles debounce + race-guard. */
runDebounced(): void;
/** Immediate (no debounce). Used by form `submit`. */
runSearch(q: string): Promise<void>;
/** Filter-change re-search — shorter debounce. */
reSearch(): void;
/** Paginate locally, then fall back to web. Idempotent while in-flight. */
loadMore(): Promise<void>;
/** Clear query + results + cancel any pending debounce (e.g. `afterNavigate`). */
reset(): void;
/** For SvelteKit `Snapshot<>` API. */
captureSnapshot(): SearchSnapshot;
restoreSnapshot(s: SearchSnapshot): void;
}
```
**Behavior invariants (copied 1:1 from the current code — do NOT change):**
- Query threshold: `trim().length > 3` triggers search, `<= 3` clears results.
- Race-guard: after every `await fetch(...)`, bail if `this.query.trim() !== q`.
- When `hits.length === 0` after local search → auto-fire web search page 1.
- `loadMore`: first drains local (offset pagination), then switches to web (pageno pagination).
- Dedup: local by `id`, web by `url`.
- `webError`: keep the message text so UI can render it.
**What stays OUT of the store:**
- URL sync (`history.replaceState` with `?q=`) → stays in `+page.svelte`.
- Dropdown visibility (`navOpen`) → stays in `+layout.svelte`.
- `afterNavigate`-reset wiring → stays in `+layout.svelte`, just calls `store.reset()`.
- SvelteKit `Snapshot<>` wiring → stays in `+page.svelte`, delegates to store.
- Filter-change re-search `$effect` → stays in `+page.svelte`, just calls `store.reSearch()`.
---
## Task 1: Failing Unit Tests for SearchStore
**Files:**
- Create: `tests/unit/search-store.test.ts`
- [ ] **Step 1: Write test file with full behavior coverage (runs red until Task 2)**
```ts
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SearchStore } from '../../src/lib/client/search.svelte';
type FetchMock = ReturnType<typeof vi.fn>;
function mockFetch(responses: Array<{ ok?: boolean; status?: number; body: unknown }>): FetchMock {
const calls = [...responses];
return vi.fn(async () => {
const r = calls.shift();
if (!r) throw new Error('fetch called more times than expected');
return {
ok: r.ok ?? true,
status: r.status ?? 200,
json: async () => r.body
} as Response;
});
}
describe('SearchStore', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('keeps results empty while query is <= 3 chars (debounced)', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([]);
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
store.query = 'abc';
store.runDebounced();
await vi.advanceTimersByTimeAsync(100);
expect(store.searching).toBe(false);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('fires local search after debounce when query > 3 chars', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [{ id: 1, title: 'Pasta', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 50, pageSize: 30 });
store.query = 'pasta';
store.runDebounced();
expect(store.searching).toBe(true);
await vi.advanceTimersByTimeAsync(100);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled());
expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?q=pasta&limit=30/);
expect(store.hits).toHaveLength(1);
expect(store.searchedFor).toBe('pasta');
expect(store.localExhausted).toBe(true); // 1 hit < pageSize → exhausted
});
it('falls back to web search when local returns zero hits', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ body: { hits: [{ url: 'https://chefkoch.de/x', title: 'Foo', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
store.query = 'pizza';
store.runDebounced();
await vi.advanceTimersByTimeAsync(100);
await vi.waitFor(() => expect(store.webHits).toHaveLength(1));
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?q=pizza&pageno=1/);
expect(store.webPageno).toBe(1);
});
it('races-guards: stale response discarded when query changed mid-flight', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [{ id: 99, title: 'Stale', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 10 });
store.query = 'stale-query';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
store.query = 'different'; // user kept typing
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled());
expect(store.hits).toEqual([]); // stale discarded
});
it('loadMore: drains local first (offset pagination)', async () => {
vi.useFakeTimers();
const page1 = Array.from({ length: 30 }, (_, i) => ({ id: i, title: `r${i}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }));
const page2 = Array.from({ length: 5 }, (_, i) => ({ id: i + 30, title: `r${i + 30}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }));
const fetchImpl = mockFetch([
{ body: { hits: page1 } },
{ body: { hits: page2 } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 });
store.query = 'meal';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(store.hits).toHaveLength(30));
expect(store.localExhausted).toBe(false);
await store.loadMore();
expect(store.hits).toHaveLength(35);
expect(fetchImpl.mock.calls[1][0]).toMatch(/offset=30/);
expect(store.localExhausted).toBe(true);
});
it('loadMore: switches to web pagination after local exhausted', async () => {
vi.useFakeTimers();
const local = [{ id: 1, title: 'local', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }];
const webP1 = [{ url: 'https://a.com', title: 'A', domain: 'a.com', snippet: null, thumbnail: null }];
const webP2 = [{ url: 'https://b.com', title: 'B', domain: 'b.com', snippet: null, thumbnail: null }];
const fetchImpl = mockFetch([
{ body: { hits: local } },
{ body: { hits: webP1 } }, // auto-fallback? No — local has 1 hit, so no fallback.
{ body: { hits: webP2 } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 });
store.query = 'soup';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
expect(store.localExhausted).toBe(true);
await store.loadMore(); // web pageno=1
expect(store.webHits).toHaveLength(1);
await store.loadMore(); // web pageno=2
expect(store.webHits).toHaveLength(2);
expect(store.webPageno).toBe(2);
});
it('web search error sets webError and marks webExhausted', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ ok: false, status: 502, body: { message: 'SearXNG unreachable' } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 10 });
store.query = 'anything';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(store.webError).toBe('SearXNG unreachable'));
expect(store.webExhausted).toBe(true);
});
it('reset(): clears query, results, and pending debounce', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([]);
const store = new SearchStore({ fetchImpl, debounceMs: 100 });
store.query = 'foobar';
store.runDebounced();
store.reset();
await vi.advanceTimersByTimeAsync(200);
expect(store.query).toBe('');
expect(store.hits).toEqual([]);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('captureSnapshot / restoreSnapshot: round-trips without re-fetching', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([]);
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
const snap: SearchSnapshot = {
query: 'lasagne',
hits: [{ id: 7, title: 'Lasagne', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }],
webHits: [],
searchedFor: 'lasagne',
webError: null,
localExhausted: true,
webPageno: 0,
webExhausted: false
};
store.restoreSnapshot(snap);
expect(store.query).toBe('lasagne');
expect(store.hits).toHaveLength(1);
store.runDebounced(); // should NOT re-fetch after restore
await vi.advanceTimersByTimeAsync(100);
expect(fetchImpl).not.toHaveBeenCalled();
const round = store.captureSnapshot();
expect(round).toEqual(snap);
});
it('filterParam option: gets appended to both local and web requests', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ body: { hits: [] } }
]);
const store = new SearchStore({
fetchImpl,
debounceMs: 10,
filterParam: () => '&domains=chefkoch.de'
});
store.query = 'curry';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/);
expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/);
});
it('reSearch: immediate re-run with current query on filter change', async () => {
vi.useFakeTimers();
let filter = '';
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ body: { hits: [] } },
{ body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
]);
const store = new SearchStore({
fetchImpl,
debounceMs: 10,
filterDebounceMs: 5,
filterParam: () => filter
});
store.query = 'broth';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
// Simulate filter change
filter = '&domains=chefkoch.de';
store.reSearch();
await vi.advanceTimersByTimeAsync(10);
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
// Last call should have filter param
const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
expect(last).toMatch(/&domains=chefkoch\.de/);
});
});
```
- [ ] **Step 2: Run tests to verify all fail with "SearchStore is not a constructor" or "Cannot find module"**
```bash
npm test -- search-store.test
```
Expected: 12 tests, all failing because `src/lib/client/search.svelte.ts` doesn't exist yet.
---
## Task 2: Implement SearchStore to pass tests
**Files:**
- Create: `src/lib/client/search.svelte.ts`
- [ ] **Step 1: Scaffold the class + types**
Create `src/lib/client/search.svelte.ts` with this content:
```ts
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
export type SearchSnapshot = {
query: string;
hits: SearchHit[];
webHits: WebHit[];
searchedFor: string | null;
webError: string | null;
localExhausted: boolean;
webPageno: number;
webExhausted: boolean;
};
export type SearchStoreOptions = {
pageSize?: number;
debounceMs?: number;
filterDebounceMs?: number;
minQueryLength?: number;
filterParam?: () => string;
fetchImpl?: typeof fetch;
};
export class SearchStore {
query = $state('');
hits = $state<SearchHit[]>([]);
webHits = $state<WebHit[]>([]);
searching = $state(false);
webSearching = $state(false);
webError = $state<string | null>(null);
searchedFor = $state<string | null>(null);
localExhausted = $state(false);
webPageno = $state(0);
webExhausted = $state(false);
loadingMore = $state(false);
private readonly pageSize: number;
private readonly debounceMs: number;
private readonly filterDebounceMs: number;
private readonly minQueryLength: number;
private readonly filterParam: () => string;
private readonly fetchImpl: typeof fetch;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private skipNextDebounce = false;
constructor(opts: SearchStoreOptions = {}) {
this.pageSize = opts.pageSize ?? 30;
this.debounceMs = opts.debounceMs ?? 300;
this.filterDebounceMs = opts.filterDebounceMs ?? 150;
this.minQueryLength = opts.minQueryLength ?? 4;
this.filterParam = opts.filterParam ?? (() => '');
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
}
}
```
- [ ] **Step 2: Implement `runDebounced`, `runSearch`, private `runWebSearch`**
Add to the class:
```ts
runDebounced(): void {
// Consumer pattern:
// $effect(() => { store.query; store.runDebounced(); });
// The bare `store.query` read registers the reactive dep; this method
// then reads `this.query` live to kick off / debounce the search.
if (this.debounceTimer) clearTimeout(this.debounceTimer);
if (this.skipNextDebounce) {
this.skipNextDebounce = false;
return;
}
const q = this.query.trim();
if (q.length < this.minQueryLength) {
this.resetResults();
return;
}
this.searching = true;
this.webHits = [];
this.webSearching = false;
this.webError = null;
this.debounceTimer = setTimeout(() => {
void this.runSearch(q);
}, this.debounceMs);
}
async runSearch(q: string): Promise<void> {
this.localExhausted = false;
this.webPageno = 0;
this.webExhausted = false;
try {
const res = await this.fetchImpl(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}`
);
const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return;
this.hits = body.hits;
this.searchedFor = q;
if (this.hits.length < this.pageSize) this.localExhausted = true;
if (this.hits.length === 0) {
await this.runWebSearch(q, 1);
}
} finally {
if (this.query.trim() === q) this.searching = false;
}
}
private async runWebSearch(q: string, pageno: number): Promise<void> {
this.webSearching = true;
try {
const res = await this.fetchImpl(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}`
);
if (this.query.trim() !== q) return;
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { message?: string };
this.webError = err.message ?? `HTTP ${res.status}`;
this.webExhausted = true;
return;
}
const body = (await res.json()) as { hits: WebHit[] };
this.webHits = pageno === 1 ? body.hits : [...this.webHits, ...body.hits];
this.webPageno = pageno;
if (body.hits.length === 0) this.webExhausted = true;
} finally {
if (this.query.trim() === q) this.webSearching = false;
}
}
```
- [ ] **Step 3: Implement `loadMore`**
```ts
async loadMore(): Promise<void> {
if (this.loadingMore) return;
const q = this.query.trim();
if (!q) return;
this.loadingMore = true;
try {
if (!this.localExhausted) {
const res = await this.fetchImpl(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}`
);
const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return;
const more = body.hits;
const seen = new Set(this.hits.map((h) => h.id));
const deduped = more.filter((h) => !seen.has(h.id));
this.hits = [...this.hits, ...deduped];
if (more.length < this.pageSize) this.localExhausted = true;
} else if (!this.webExhausted) {
const nextPage = this.webPageno + 1;
const wasEmpty = this.webHits.length === 0;
if (wasEmpty) this.webSearching = true;
try {
const res = await this.fetchImpl(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}`
);
if (this.query.trim() !== q) return;
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { message?: string };
this.webError = err.message ?? `HTTP ${res.status}`;
this.webExhausted = true;
return;
}
const body = (await res.json()) as { hits: WebHit[] };
const more = body.hits;
const seen = new Set(this.webHits.map((h) => h.url));
const deduped = more.filter((h) => !seen.has(h.url));
if (deduped.length === 0) {
this.webExhausted = true;
} else {
this.webHits = [...this.webHits, ...deduped];
this.webPageno = nextPage;
}
} finally {
if (this.query.trim() === q) this.webSearching = false;
}
}
} finally {
this.loadingMore = false;
}
}
```
- [ ] **Step 4: Implement `reSearch`, `reset`, `resetResults`, snapshot methods**
```ts
reSearch(): void {
const q = this.query.trim();
if (q.length < this.minQueryLength) return;
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.searching = true;
this.webHits = [];
this.webSearching = false;
this.webError = null;
this.debounceTimer = setTimeout(() => void this.runSearch(q), this.filterDebounceMs);
}
reset(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = null;
this.query = '';
this.resetResults();
}
private resetResults(): void {
this.hits = [];
this.webHits = [];
this.searchedFor = null;
this.searching = false;
this.webSearching = false;
this.webError = null;
this.localExhausted = false;
this.webPageno = 0;
this.webExhausted = false;
}
captureSnapshot(): SearchSnapshot {
return {
query: this.query,
hits: this.hits,
webHits: this.webHits,
searchedFor: this.searchedFor,
webError: this.webError,
localExhausted: this.localExhausted,
webPageno: this.webPageno,
webExhausted: this.webExhausted
};
}
restoreSnapshot(s: SearchSnapshot): void {
this.skipNextDebounce = true;
this.query = s.query;
this.hits = s.hits;
this.webHits = s.webHits;
this.searchedFor = s.searchedFor;
this.webError = s.webError;
this.localExhausted = s.localExhausted;
this.webPageno = s.webPageno;
this.webExhausted = s.webExhausted;
}
```
- [ ] **Step 5: Run tests, iterate until all green**
```bash
npm test -- search-store.test
```
Expected: all 12 tests pass.
- [ ] **Step 6: `npm run check`**
```bash
npm run check
```
Expected: 0 errors, 0 warnings in `search.svelte.ts`.
- [ ] **Step 7: Commit**
```bash
git add src/lib/client/search.svelte.ts tests/unit/search-store.test.ts
git commit -m "feat(search): SearchStore fuer Live-Search mit Web-Fallback
Extrahiert die duplizierte Such-Logik aus +page.svelte und
+layout.svelte in eine gemeinsame Klasse. Pure Datenschicht
mit injizierbarem fetch — UI-Concerns (URL-Sync, Dropdown,
Snapshot) bleiben in den Komponenten."
```
---
## Task 3: Migrate `+layout.svelte` header dropdown
**Why first:** Smaller surface than `+page.svelte`, no snapshot API, no URL sync. If the store is wrong, here we find out with less code at risk.
**Files:**
- Modify: `src/routes/+layout.svelte:20-200`
- [ ] **Step 1: Add import**
At the top of `<script>`:
```ts
import { SearchStore } from '$lib/client/search.svelte';
import { searchFilterStore } from '$lib/client/search-filter.svelte';
```
(Latter is already imported — just confirm.)
- [ ] **Step 2: Replace the 11 `$state` declarations (navQuery, navHits, navWebHits, navSearching, navWebSearching, navWebError, navLocalExhausted, navWebPageno, navWebExhausted, navLoadingMore, debounceTimer) with one store instance.**
Keep these (UI-only): `navOpen`, `navContainer`, `menuOpen`, `menuContainer`.
New:
```ts
const navStore = new SearchStore({
pageSize: 30,
filterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
});
```
Remove the local `filterParam()` helper — the store owns it now.
- [ ] **Step 3: Replace the big `$effect` (lines 52109) with a 3-line `$effect`**
```ts
$effect(() => {
// Bare reads register the reactive deps; then kick the store.
const q = navStore.query;
navStore.runDebounced();
// navOpen follows query length: open while typing, close when cleared.
navOpen = q.trim().length > 3;
});
```
- [ ] **Step 4: Replace `loadMoreNav` function (lines 111159) with a pass-through**
```ts
function loadMoreNav() {
return navStore.loadMore();
}
```
Or inline `onclick={() => navStore.loadMore()}` at the call-site — pick the less disruptive option when looking at the template.
- [ ] **Step 5: Replace `submitNav` (lines 161167)**
```ts
function submitNav(e: SubmitEvent) {
e.preventDefault();
const q = navStore.query.trim();
if (!q) return;
navOpen = false;
void goto(`/?q=${encodeURIComponent(q)}`);
}
```
- [ ] **Step 6: Replace `pickHit` (lines 185190)**
```ts
function pickHit() {
navOpen = false;
navStore.reset();
}
```
- [ ] **Step 7: Update `afterNavigate` (lines 192+)**
```ts
afterNavigate(() => {
navStore.reset();
navOpen = false;
menuOpen = false;
// ... rest of existing body (wishlist refresh etc.)
});
```
- [ ] **Step 8: Update the template**
Every `navQuery``navStore.query`, every `navHits``navStore.hits`, etc. This is a mechanical rename — use find+replace scoped to `src/routes/+layout.svelte` only.
Mapping:
- `navQuery``navStore.query`
- `navHits``navStore.hits`
- `navWebHits``navStore.webHits`
- `navSearching``navStore.searching`
- `navWebSearching``navStore.webSearching`
- `navWebError``navStore.webError`
- `navLocalExhausted``navStore.localExhausted`
- `navWebPageno``navStore.webPageno` (if referenced in template)
- `navWebExhausted``navStore.webExhausted`
- `navLoadingMore``navStore.loadingMore`
`bind:value={navQuery}` on the `<input>``bind:value={navStore.query}`.
- [ ] **Step 9: Run checks**
```bash
npm run check
npm test
```
Both must be clean.
- [ ] **Step 10: Smoke-test dev server manually**
```bash
npm run dev
```
Open a recipe page → type in header dropdown → verify: dropdown opens, shows local hits, falls back to web for unknown query, "+ weitere Ergebnisse" paginates, clicking a hit closes the dropdown, navigating back/forward clears the dropdown.
- [ ] **Step 11: Commit**
```bash
git add src/routes/+layout.svelte
git commit -m "refactor(layout): Header-Dropdown nutzt SearchStore
Ersetzt die 11 lokalen \$state und den Debounce-Effect durch
eine SearchStore-Instanz. Nav-Open-Toggle bleibt lokal, weil
UI-Concern."
```
---
## Task 4: Migrate `+page.svelte` home
**Why after Task 3:** The store is now field-tested. Home adds snapshot + URL sync + filter-change re-search on top.
**Files:**
- Modify: `src/routes/+page.svelte:1-371`
- [ ] **Step 1: Add imports**
```ts
import { SearchStore, type SearchSnapshot } from '$lib/client/search.svelte';
```
- [ ] **Step 2: Remove the duplicated `$state` block (lines 1732)**
Delete: `query`, `hits`, `webHits`, `searching`, `webSearching`, `webError`, `searchedFor`, `localExhausted`, `webPageno`, `webExhausted`, `loadingMore`, `skipNextSearch`, `debounceTimer`.
Keep: `quote`, `recent`, `favorites` (not search-related), and all `all*` state (All-Recipes listing — unrelated to search).
Add:
```ts
const store = new SearchStore({
pageSize: LOCAL_PAGE,
filterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
});
```
Remove the local `filterParam()` helper (lines 224227).
- [ ] **Step 3: Rewire the `Snapshot<>` API (lines 5083)**
```ts
export const snapshot: Snapshot<SearchSnapshot> = {
capture: () => store.captureSnapshot(),
restore: (s) => store.restoreSnapshot(s)
};
```
Delete the old `SearchSnapshot` local type alias (it's now imported).
- [ ] **Step 4: Replace the two search `$effect`s (filter-change + query-change) with two one-liners**
Remove lines 188199 (filter-change effect) and lines 322347 (query-change effect).
Add:
```ts
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
store.query; // register reactive dep
store.runDebounced();
});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
searchFilterStore.active;
store.reSearch();
});
```
- [ ] **Step 5: Keep the URL-sync `$effect` as-is, but read from `store.query`**
```ts
$effect(() => {
if (typeof window === 'undefined') return;
const q = store.query.trim();
const url = new URL(window.location.href);
const current = url.searchParams.get('q') ?? '';
if (q === current) return;
if (q) url.searchParams.set('q', q);
else url.searchParams.delete('q');
history.replaceState(history.state, '', url.toString());
});
```
- [ ] **Step 6: Update `onMount` URL-restore**
```ts
const urlQ = ($page.url.searchParams.get('q') ?? '').trim();
if (urlQ) store.query = urlQ;
```
- [ ] **Step 7: Delete `runSearch` and `loadMore` local functions (lines 229320)**
The store provides both. Template references `loadMore` → change to `store.loadMore()`.
- [ ] **Step 8: Update `submit`**
```ts
function submit(e: SubmitEvent) {
e.preventDefault();
const q = store.query.trim();
if (q.length <= 3) return;
void store.runSearch(q);
}
```
- [ ] **Step 9: Update the template (same mechanical rename as Task 3)**
`query``store.query`, `hits``store.hits`, etc. for all 11 fields.
`bind:value={query}``bind:value={store.query}`.
`activeSearch` derived stays: `const activeSearch = $derived(store.query.trim().length > 3);`
- [ ] **Step 10: Run checks**
```bash
npm run check
npm test
```
- [ ] **Step 11: Verify file is shorter than before**
```bash
wc -l src/routes/+page.svelte
```
Expected: under 700 lines (was 808). Target from roadmap: under 700 L.
```bash
wc -l src/routes/+layout.svelte
```
Expected: under 600 lines (was 681). Target from roadmap: under 600 L.
- [ ] **Step 12: Smoke-test dev manually**
- Type "lasagne" in home → local hits appear.
- Type "pizza margherita" → web fallback.
- Deep-link `/?q=lasagne` → query restored, results visible.
- Navigate to recipe → back → home query + results preserved (snapshot).
- Change domain filter while query is active → results re-fetch with new filter.
- [ ] **Step 13: Commit**
```bash
git add src/routes/+page.svelte
git commit -m "refactor(home): Live-Search auf SearchStore migriert
Entfernt 11 duplizierte \$state, runSearch, loadMore und beide
Debounce-Effekte. URL-Sync, Snapshot und Filter-Re-Search bleiben
hier — aber alle delegieren an den Store."
```
---
## Task 5: Remote E2E smoke (optional — only if CI deploy happens)
**Trigger:** Only run this task if CI builds the `search-state-store` branch and deploys to `kochwas-dev.siegeln.net`. Otherwise skip to Task 6.
**Files:**
- Run: existing `tests/e2e/remote/search.spec.ts`
- [ ] **Step 1: Run remote suite**
```bash
npm run test:e2e:remote -- search.spec.ts
```
Expected: 4/4 pass (existing coverage is sufficient — no new specs needed for a pure refactor).
---
## Task 6: Self-review + merge prep
**Files:**
- Review: all changed files
- [ ] **Step 1: `npm test` full suite**
```bash
npm test
```
Expected: all pass (previous count + 12 new SearchStore tests).
- [ ] **Step 2: `npm run check` full repo**
```bash
npm run check
```
Expected: 0 errors, 0 warnings.
- [ ] **Step 3: `git diff main...HEAD` review**
```bash
git diff main...HEAD --stat
git log main..HEAD --oneline
```
Expected commits:
1. `feat(search): SearchStore fuer Live-Search mit Web-Fallback`
2. `refactor(layout): Header-Dropdown nutzt SearchStore`
3. `refactor(home): Live-Search auf SearchStore migriert`
- [ ] **Step 4: Push branch**
```bash
git push -u origin search-state-store
```
CI builds branch-tagged image → user tests on `kochwas-dev.siegeln.net` → merges to main when clean.
---
## Risk Notes
- **Svelte 5 `$state` in classes:** Standard pattern in this repo (`SearchFilterStore`, `PWAStore`). Works.
- **Two instances of `SearchStore` simultaneously:** Each has its own timer + state. No shared mutable state between them — verified because the store has no static fields.
- **Snapshot restore racing with `runDebounced`:** Handled via `skipNextDebounce` flag. Same mechanism as the current `skipNextSearch` in `+page.svelte`.
- **Filter change on home while query is empty:** `reSearch()` early-exits when `q.length < minQueryLength`. Safe.
- **`afterNavigate` firing during an in-flight search:** `reset()` clears timer and mutates `query`. Any in-flight fetch will race-guard-fail on the next `if (this.query.trim() !== q) return;`. Results get dropped, which is the desired behavior.
## Deferred — NOT in this plan
- **Search-Store-Tests mit echtem Browser-`$effect`:** Would need `@sveltejs/vite-plugin-svelte` test setup with component mount. Current Vitest setup is Node-only. Skip — the injected-fetch unit tests cover the state machine.
- **Shared store instance (singleton) instead of per-consumer:** Rejected during design — would couple home and header search semantically.
- **Web-Hit-Cache im Store:** Out of scope. The roadmap explicitly scopes this phase to state extraction, not perf work.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,336 @@
# Foto-Rezept-Magie — Design Spec
**Status:** approved (brainstorming)
**Datum:** 2026-04-21
**Ziel-Release:** v1.3.0
## 1. Motivation & Scope
Nutzer sollen ein **gedrucktes oder handgeschriebenes Rezept** fotografieren können. Das Foto wird an ein Vision-LLM (Gemini 2.5 Flash) gesendet, dort zu einer strukturierten Recipe-Shape extrahiert, und direkt in einen vorausgefüllten `RecipeEditor` gepackt — der Nutzer korrigiert bei Bedarf und speichert. Das Foto selbst wird nie persistiert.
**In Scope (v1):**
- Einzelnes Foto von gedrucktem Rezept ODER Handschrift.
- Extraktion von Titel, Portionen, Zeiten, Zutaten (mit Menge/Einheit/Name), Zubereitungsschritten.
- Auslöse-Button als `Camera`-Icon (lucide) im Header, disabled wenn offline oder ohne API-Key.
- Direkter Flow in den `RecipeEditor` (kein separater Preview-Schritt).
- Server-seitiger Gemini-Call mit structured-output-Schema, Zod-Validierung, 1× Retry bei Schema-Fehler.
- Hartes Nicht-Speichern des Fotos nach dem Call.
**Explizit Out-of-Scope (v1):**
- Multi-Foto (Kochbuch-Doppelseite): Endpoint nimmt ein Bild entgegen; Erweiterung auf Array bei Bedarf.
- Extraktion von `image_path` aus dem Bild (Dish-Crop aus Kochbuchseite).
- Foto-Backup / Persistenz des Input-Fotos.
- Claude als Fallback.
- Interpretierende Felder: `cuisine`, `category`, `tags`, freie `description`.
- Foto-vom-Gericht → AI-erfindet-Rezept (anderer Use-Case).
## 2. User Flow
```
Header (Camera-Icon, lucide)
/new/from-photo (File-Picker: <input type="file" accept="image/*" capture="environment">)
▼ Nutzer wählt/knipst Foto — File bleibt nur im Browser-State
▼ POST /api/recipes/extract-from-photo (multipart/form-data)
│ Server: MIME + Größe validieren, sharp-Preprocess, Gemini-Call, Response
│ Foto wird NICHT persistiert.
Seite swappt Spinner → <RecipeEditor initialData={recipe}>
▼ Nutzer korrigiert, klickt „Speichern" (Editor-Save-Pfad — ob bestehend oder neu: siehe §11)
/recipes/:id
```
**Invarianten:**
- Extraktion erzeugt **kein** DB-Record. Erst der Save-Klick im Editor schreibt.
- Bei Tab-Close während Extraktion: kein Müll in der DB, AbortController-fähiger Fetch.
- Offline: Kamera-Icon im Header nicht geklickbar; falls trotzdem Route geöffnet, klare Offline-Meldung.
## 3. Komponenten & Dateien
**Neue Dateien**
| Pfad | Zweck |
|---|---|
| `src/routes/new/from-photo/+page.svelte` | Shell. States: `idle` / `loading` / `success` / `error:<code>`. |
| `src/lib/server/ai/gemini-client.ts` | Thin wrapper: `extractRecipeFromImage(buffer, mime): Promise<Partial<Recipe>>`. Liest `GEMINI_API_KEY`, `GEMINI_MODEL`, `GEMINI_TIMEOUT_MS` aus `$env/dynamic/private`. |
| `src/lib/server/ai/recipe-extraction-prompt.ts` | System-Prompt (DE) + JSON-Schema für Gemini `responseSchema`. Isoliert, weil iterabel. |
| `src/lib/server/ai/description-phrases.ts` | 50er-Pool von Magie-Phrasen für das `description`-Feld. Export `pickRandomPhrase(): string`. Siehe §5a. |
| `src/lib/server/ai/image-preprocess.ts` | `sharp`-basierter Resize (≤1600px lange Kante) + JPEG re-encode (quality 85) + Metadata-Strip. HEIC → JPEG. |
| `src/routes/api/recipes/extract-from-photo/+server.ts` | POST. Multipart-Parse, Validierung, preprocess, Gemini-Call, Zod-Validierung, Response. |
| `src/lib/client/photo-upload.svelte.ts` | Frontend-Store für Upload-Zustand. |
| `tests/unit/ai/recipe-extraction-prompt.test.ts` | Schema-Ping, Retry-Pfad, Zod-Ablehnung. |
| `tests/unit/ai/image-preprocess.test.ts` | Resize, HEIC, Metadata-Strip. |
| `tests/unit/ai/gemini-client.test.ts` | Timeout, 429-no-retry, 5xx-1x-retry, Network-Fehler. |
| `tests/unit/ai/description-phrases.test.ts` | Pool hat 50 Einträge, alle unique non-empty, `pickRandomPhrase` liefert nur Pool-Einträge. |
| `tests/api/extract-from-photo.test.ts` | Happy-Path, 413, 415, 422 (NO_RECIPE_IN_IMAGE). |
| `tests/e2e/remote/photo-import.spec.ts` | Kamera-Icon, Upload-Fixture (Endpoint gestubt), Editor-Prefill, Save, Offline-State. |
| `tests/fixtures/photo-recipe/` | 3 Fixture-Fotos: gedrucktes Rezept, Handschrift, No-Recipe-Bild. |
**Geänderte Dateien**
| Pfad | Änderung |
|---|---|
| `src/routes/+layout.svelte` | Header: `Camera`-Icon, `aria-label="Rezept aus Foto erstellen"`. Nur gerendert wenn `GEMINI_API_KEY` gesetzt (Graceful Degradation). Disabled wenn offline (`networkStore`). Führt zu `/new/from-photo`. |
| `src/lib/components/RecipeEditor.svelte` | Akzeptiert optionale `initialData?: Partial<Recipe>`-Prop. Wenn gesetzt, Felder vorbefüllen, kein DB-Round-trip. Heute liest der Editor über eine Rezept-ID — dieser Pfad wird abstrahiert. |
| `Dockerfile` | `sharp` im Native-Build-Stage ergänzen (wie `better-sqlite3`). |
| `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example` | Env-Vars `GEMINI_API_KEY`, `GEMINI_MODEL`, `GEMINI_TIMEOUT_MS` ergänzen. |
| `docs/OPERATIONS.md` | Abschnitt zu Gemini-Config + Recreate-Hinweis bei Env-Änderung. |
| `docs/ARCHITECTURE.md` | AI-Extraktionspfad ergänzen. |
| `CLAUDE.md` | Zeile in Gotcha-Tabelle: Graceful Degradation ohne Key + `sharp` im Build-Stage. |
**Keine DB-Migration.** Recipe-Shape bleibt; der Endpoint produziert ein `Partial<Recipe>` im Response-Body.
## 4. API-Contract
**`POST /api/recipes/extract-from-photo`**
Request: `multipart/form-data`
- `photo`: File. Erlaubt: `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif`.
- Max 8 MB (vor Preprocess).
Response 200:
```json
{
"recipe": {
"title": "Zürcher Geschnetzeltes",
"description": "Aus dem Bild herbeigezaubert.",
"servings_default": 4,
"servings_unit": "Portionen",
"prep_time_min": 20,
"cook_time_min": 15,
"total_time_min": null,
"cuisine": null,
"category": null,
"image_path": null,
"source_url": null,
"source_domain": null,
"ingredients": [
{ "position": 1, "quantity": 500, "unit": "g", "name": "Kalbsgeschnetzeltes", "note": null, "section": null },
{ "position": 2, "quantity": 200, "unit": "ml", "name": "Rahm", "note": null, "section": null }
],
"steps": [
{ "position": 1, "text": "Fleisch in heißer Pfanne kurz anbraten, herausnehmen." }
],
"tags": []
}
}
```
Response Fehler-Codes:
| Status | `code` | Bedeutung |
|---|---|---|
| 413 | `PAYLOAD_TOO_LARGE` | Photo > 8 MB. |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | MIME nicht in der Whitelist. |
| 422 | `NO_RECIPE_IN_IMAGE` | AI-Output valide, aber `title` leer oder (`ingredients.length === 0` UND `steps.length === 0`). |
| 429 | `AI_RATE_LIMITED` | Gemini 429 durchgereicht. |
| 503 | `AI_TIMEOUT` | Gemini-Timeout (Default 20 s). |
| 503 | `AI_FAILED` | Gemini-5xx nach 1 Retry ODER Schema-Validierung nach 1 Retry fehlgeschlagen. |
| 503 | `AI_NOT_CONFIGURED` | `GEMINI_API_KEY` leer — Endpoint sollte dann ohnehin nicht erreichbar sein via UI, belt-and-suspenders. |
## 5. Prompt-Strategie
Datei: `src/lib/server/ai/recipe-extraction-prompt.ts`
- **Sprache:** Deutsch.
- **Rolle:** „Du bist ein Rezept-Extraktions-Assistent."
- **Regeln:**
- Nur was lesbar auf dem Bild steht, ins Ergebnis. Sonst `null` oder leeres Array.
- Zutatenmengen: Zahl in `quantity`, Einheit separat (`g`, `ml`, `EL`, `TL`, `Stück`, `Prise`…).
- Bruchteile (`½`, `¼`, `1 ½`) zu Dezimalzahlen.
- Zubereitungsschritte: pro erkennbarer Nummerierung/Absatz ein Schritt.
- `description` wird server-seitig **nach** dem AI-Call aus einem 50er-Pool zufällig gewählt (`description-phrases.ts`, siehe §5a). Die AI bekommt `description` gar nicht erst im Schema — keine Halluzinationsfläche.
- **Output:** Gemini `responseMimeType: "application/json"` + `responseSchema`. Strict-typed, keine zusätzlichen Keys.
- **Temperature:** `0.1`.
- **Retry bei Schema-Fehler:** Genau 1 zusätzlicher Call mit Appendix „Dein letztes JSON war invalid. Schema: … Bitte nur JSON zurück." Dann `AI_FAILED`.
Zod-Schema spiegelt das Response-Schema serverseitig und wird auf die Gemini-Antwort angewendet.
## 5a. Description-Phrasen-Pool
Datei: `src/lib/server/ai/description-phrases.ts`
50 deutsche Magie-Phrasen, zufällig gezogen pro Extraktions-Call. Die Auswahl geschieht server-seitig im Endpoint, nachdem die AI-Antwort validiert wurde. Der Nutzer kann die Phrase im Editor weiter editieren, sie ist also ein Starter, kein Lock-in.
```ts
export const DESCRIPTION_PHRASES: readonly string[] = [
'Mit dem Zauberstab aus dem Kochbuch geholt.',
'Foto-Magie frisch aus dem Ofen.',
'Aus dem Bild herbeigezaubert.',
'Ein Klick, ein Foto, fertig.',
'Knipsen statt Abtippen.',
'Von der Buchseite direkt in die Pfanne.',
'Die Kamera hat mitgelesen.',
'Abrakadabra — Rezept da.',
'Per Linse in die Küche teleportiert.',
'Von Oma abfotografiert, von der KI entziffert.',
'Frisch aus dem Bilderrahmen.',
'Klick, zisch, Rezept.',
'Das Foto wurde überredet, sich zu verraten.',
'Schnappschuss zur Schüssel.',
'Einmal lesen lassen, schon da.',
'Keine Hand hat dieses Rezept abgetippt.',
'Vom Bild in die Bratpfanne.',
'Papier ist geduldig, das Foto war es auch.',
'Eine Seite, ein Foto, ein Rezept.',
'Die KI hat drübergeschielt.',
'Handschriftlich entziffert — oder zumindest versucht.',
'Aus der Linse in die Liste.',
'Vom Küchentisch zur Kachel.',
'Knips und weg — zumindest der Zettel.',
'Das Bild hat geredet.',
'Keine Tippfehler, nur Sehfehler.',
'Per Foto eingebürgert.',
'Rezept-Übersetzung aus dem Bild.',
'Die Seite hat sich verraten.',
'Blitzlicht und dann Gulasch.',
'Ein Augenzwinkern der Kamera genügte.',
'Geknipst, gelesen, gespeichert.',
'Fotografische Gedächtnishilfe.',
'Aus der Schublade ans Licht.',
'Das Rezept stand schon da — wir haben nur hingeguckt.',
'Zaubertrick mit Kamera.',
'Vom Papier befreit.',
'Ein Foto sagt mehr als tausend Zutatenlisten.',
'Eingescannt, rausgelesen, reingeschrieben.',
'Die Kamera als Küchenhilfe.',
'Handy hoch, Rezept runter.',
'Aus dem Kochbuch gebeamt.',
'Ein scharfes Foto, ein klares Rezept.',
'Vom Regal zur App in einem Schritt.',
'Aus dem Bild geschöpft wie Suppe aus dem Topf.',
'Optisch erfasst, digital serviert.',
'Das Kleingedruckte hat die KI gelesen.',
'Vom Kladdenzettel in die Datenbank.',
'Kurz gezückt, schon gekocht.',
'Kein Schreibkrampf, nur ein Klick.'
];
export function pickRandomPhrase(): string {
return DESCRIPTION_PHRASES[Math.floor(Math.random() * DESCRIPTION_PHRASES.length)];
}
```
**Invariant:** Genau 50 Einträge, alle non-empty, alle unique. Unit-Test prüft das.
## 6. Fehlerbehandlung
**Client-Zustände auf `/new/from-photo`:**
| State | UI |
|---|---|
| `idle` | `Camera`-Button groß mittig, Text „Foto wählen oder aufnehmen". Hilfetext: „Gedrucktes Rezept oder Handschrift. Eine Seite, scharf, gut ausgeleuchtet." |
| `loading` | `Loader2` (spin) + Text „Lese das Rezept…". `X`-Button für Abbrechen (AbortController). |
| `success` | `<RecipeEditor initialData={recipe}>`. Top-Banner mit `Wand2`: „Aus Foto erstellt — bitte prüfen und ggf. korrigieren." Verschwindet nach erstem Feld-Edit. |
| `error: NO_RECIPE_IN_IMAGE` | Yellow-Box. Buttons: `Camera` „Anderes Foto" (→ idle), `FilePlus` „Leer anlegen" (→ leerer Editor). |
| `error: AI_TIMEOUT` / `AI_RATE_LIMITED` / `AI_FAILED` | Red-Toast, Grund. `RotateCw` „Nochmal versuchen" — reused das gleiche File-Objekt, kein Re-Upload durch den Nutzer. |
| `error: PAYLOAD_TOO_LARGE` | Toast „Foto zu groß (max 8 MB). In besserer Beleuchtung neu aufnehmen." |
| Offline (auf Route) | Hinweis „Diese Funktion braucht Internet." |
**A11y:** Lade-State `aria-live="polite"`, Fehler-Boxen `role="alert"`, Kamera-Icon mit `aria-label`.
**Server-Seite:**
- Gemini-Call mit `AbortSignal`, Default-Timeout `GEMINI_TIMEOUT_MS` (20000).
- Retry-Matrix:
| Gemini-Signal | Verhalten |
|---|---|
| 429 | `AI_RATE_LIMITED`, kein Retry. |
| Network/5xx | 1× Retry mit 500 ms backoff, dann `AI_FAILED`. |
| Invalid JSON | 1× Retry mit Append-Prompt, dann `AI_FAILED`. |
| Valid JSON aber Schema-invalid | gleicher Pfad wie Invalid JSON. |
| Timeout | `AI_TIMEOUT`, kein Retry. |
- **Logging:** `console.warn` mit `{ code, durationMs, imageKB }`**ohne** Prompt/Response-Inhalt (Privacy).
**Icons (alle aus `lucide-svelte`):**
| Zweck | Icon |
|---|---|
| Header-Button | `Camera` |
| Lade-State | `Loader2` (spin) |
| Erfolgs-Banner | `Wand2` |
| Fehler | `AlertTriangle` |
| „Nochmal versuchen" | `RotateCw` |
| „Anderes Foto" | `Camera` |
| „Leer anlegen" | `FilePlus` |
| Abbrechen (Loading) | `X` |
## 7. Sicherheit / Missbrauch
- **Rate-Limit:** 10 Requests/Min pro IP, simple In-Memory-Throttle im Endpoint. Schützt vor versehentlichem Dauer-Tappen und Kosten-Runaways. Übertrieben für's Heimnetz, aber billig einzubauen.
- **MIME-Validierung nicht blind client-seitig** — Buffer-Header prüfen (`sharp` metadata) nach Empfang.
- **`.heic`/`.heif`** funktioniert, wenn `sharp` mit `libheif` gebaut ist (beim offiziellen sharp-arm64-Build dabei). Fixture-Test dafür.
- **Kein Auth** (Kochwas-Policy). Key stays server-side.
- **Privacy-Statement** im OPERATIONS.md: „Fotos gehen einmal an Google Gemini und werden danach nicht gespeichert. Gemini nutzt API-Daten im Paid-Tier nicht für Training."
## 8. Konfiguration
Env-Vars (alle in `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example` ergänzen):
| Var | Default | Zweck |
|---|---|---|
| `GEMINI_API_KEY` | — (required) | Ohne Key: Feature graceful deaktiviert. |
| `GEMINI_MODEL` | `gemini-2.5-flash` | Modell-Wechsel (z.B. auf `gemini-2.5-pro`) ohne Rebuild. |
| `GEMINI_TIMEOUT_MS` | `20000` | Timeout für Vision-Call. |
**Wichtig:** Env-Änderungen greifen erst nach `docker compose up -d --force-recreate`, nicht nach `restart` (siehe Auto-Memory `project_deploy_env_recreate.md`).
## 9. Testing
**Unit (Vitest, mocked Gemini):**
- `image-preprocess.test.ts`: Resize, HEIC→JPEG, Metadata-Strip, JPEG-Qualität.
- `recipe-extraction-prompt.test.ts`: Prompt enthält Schema; Zod akzeptiert gültige Response; Zod lehnt invalide Response ab; Retry-Logik greift genau 1×.
- `gemini-client.test.ts`: Timeout, 429-no-retry, 5xx-1x-retry, Network-Fehler.
**API (SvelteKit-Endpoint, gemockter Gemini-Client):**
- `tests/api/extract-from-photo.test.ts`: Happy-Path mit Fixture-JPEG; 413 bei >8MB; 415 bei nicht-Bild; 422 bei Titel-OK-aber-0-Ingredients-UND-0-Steps; 503 mit `AI_NOT_CONFIGURED` wenn Key fehlt.
**E2E (Playwright gegen `kochwas-dev.siegeln.net`):**
- `tests/e2e/remote/photo-import.spec.ts`: Kamera-Icon-Klick; File-Upload (Endpoint gestubt, kein echter Gemini-Call); Editor-Prefill; Save; Redirect auf `/recipes/:id`; Kamera-Icon-disabled bei `context.setOffline(true)`.
**Fixtures:** `tests/fixtures/photo-recipe/`: gedruckte Seite, Handschrift-Karte, No-Recipe-Bild.
**Explizit nicht getestet:** Die Gemini-Vision-Qualität selbst. Das ist Model-Verhalten, nicht unser Code. Manuelle Verifikation nach Deploy.
## 10. PWA / Service Worker
- `/new/from-photo` in den Shell-Pre-Cache aufnehmen.
- Feature funktioniert nur online — Offline-State wird bewusst gehandhabt (siehe §6).
- Service-Worker ändert nichts am Extract-Endpoint (keine SW-Cachung für `/api/recipes/extract-from-photo`).
## 11. Offene Kleinigkeiten (in Planung zu entscheiden)
- **Save-Endpoint:** Ob der bestehende Editor-Save-Endpoint das Anlegen eines Rezepts aus Scratch unterstützt, oder ob `insertRecipe` über einen neuen POST `/api/recipes` exponiert werden muss — vor dem Planning prüfen.
- **Sharp Build-Stage:** Verifizieren, dass das offizielle `sharp`-npm-Package auf arm64 mit libheif-Support ausgeliefert wird; andernfalls Build-Stage-Rezept ähnlich zu `better-sqlite3`.
- **Rate-Limit-Impl:** In-Memory-LRU oder Redis-like überflüssig — `Map<ip, {count, resetAt}>` reicht.
## 12. Akzeptanz-Kriterien
- [ ] Kamera-Icon in der Kopfzeile sichtbar, führt zu `/new/from-photo`.
- [ ] Kamera-Icon unsichtbar wenn `GEMINI_API_KEY` leer.
- [ ] Kamera-Icon disabled wenn offline (`networkStore.online === false`).
- [ ] File-Picker öffnet mobile Rückkamera direkt (`capture="environment"`).
- [ ] Gedrucktes Fixture-Rezept wird vom Prompt + Mock-Gemini-Response in gültige Recipe-Shape überführt.
- [ ] Handschrift-Fixture ebenso (Mock).
- [ ] No-Recipe-Fixture → 422 `NO_RECIPE_IN_IMAGE` → UI zeigt Yellow-Box mit beiden Buttons.
- [ ] Editor öffnet mit vorbefüllten Feldern, Nutzer kann editieren, Speichern navigiert zu `/recipes/:id`.
- [ ] Foto-Datei wird nach Request nicht auf Disk gefunden (Test-Assertion im API-Test).
- [ ] Build im Dockerfile-arm64-Stage erfolgreich mit `sharp`.
- [ ] `npm test` + `npm run check` grün.

836
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "kochwas",
"version": "0.1.0",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
@@ -14,7 +14,8 @@
"format": "prettier --write .",
"render:icons": "node scripts/render-icons.mjs",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
"test:e2e:ui": "playwright test --ui",
"test:e2e:remote": "playwright test --config=playwright.remote.config.ts"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
@@ -34,12 +35,15 @@
"vitest": "^2.1.4"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@types/archiver": "^7.0.0",
"@types/yauzl": "^2.10.3",
"archiver": "^7.0.1",
"better-sqlite3": "^11.5.0",
"linkedom": "^0.18.5",
"lucide-svelte": "^1.0.1",
"node-addon-api": "^8.7.0",
"node-gyp": "^12.3.0",
"yauzl": "^3.3.0",
"zod": "^3.23.8"
}

View File

@@ -5,6 +5,7 @@ import { defineConfig } from '@playwright/test';
// Preview-Server (kein Dev-Server, damit der SW registrierbar ist).
export default defineConfig({
testDir: 'tests/e2e',
testIgnore: ['tests/e2e/remote/**'],
fullyParallel: false,
reporter: 'list',
use: {

View File

@@ -0,0 +1,32 @@
import { defineConfig } from '@playwright/test';
// Zweite Playwright-Config fuer E2E-Smoketests gegen ein deployed
// Environment (standardmaessig kochwas-dev.siegeln.net).
//
// Getrennt von playwright.config.ts, weil diese Tests:
// - keinen lokalen Preview-Server starten
// - gegen eine echte Datenbank laufen (daher workers: 1, afterEach-Cleanup)
// - Service-Worker-Lifecycle nicht manipulieren (das macht offline.spec.ts lokal)
//
// Ausfuehrung: npm run test:e2e:remote
// Ziel-URL ueberschreiben: E2E_REMOTE_URL=https://... npm run test:e2e:remote
const BASE_URL = process.env.E2E_REMOTE_URL ?? 'https://kochwas-dev.siegeln.net';
export default defineConfig({
testDir: 'tests/e2e/remote',
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-remote' }]],
use: {
baseURL: BASE_URL,
headless: true,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
// Service-Worker blocken: Diese Suite testet Live-API-Verhalten gegen
// den Server, keine PWA-Features (dafuer offline.spec.ts lokal). Die
// frische SW-Registrierung pro Context akkumulierte im Single-Worker-
// Run Browser-State und crashte Chromium zufaellig nach 20-30 Specs.
serviceWorkers: 'block'
}
});

View File

@@ -0,0 +1,76 @@
import type { Recipe } from '$lib/types';
export type UploadStatus = 'idle' | 'loading' | 'success' | 'error';
export class PhotoUploadStore {
status = $state<UploadStatus>('idle');
recipe = $state<Recipe | null>(null);
errorCode = $state<string | null>(null);
errorMessage = $state<string | null>(null);
lastFile = $state<File | null>(null);
private controller: AbortController | null = null;
private readonly fetchImpl: typeof fetch;
constructor(opts: { fetchImpl?: typeof fetch } = {}) {
this.fetchImpl = opts.fetchImpl ?? fetch;
}
async upload(file: File): Promise<void> {
this.lastFile = file;
await this.doUpload(file);
}
async retry(): Promise<void> {
if (this.lastFile) await this.doUpload(this.lastFile);
}
reset(): void {
this.status = 'idle';
this.recipe = null;
this.errorCode = null;
this.errorMessage = null;
this.lastFile = null;
this.controller?.abort();
this.controller = null;
}
abort(): void {
this.controller?.abort();
}
private async doUpload(file: File): Promise<void> {
this.status = 'loading';
this.recipe = null;
this.errorCode = null;
this.errorMessage = null;
this.controller = new AbortController();
const fd = new FormData();
fd.append('photo', file);
try {
const res = await this.fetchImpl('/api/recipes/extract-from-photo', {
method: 'POST',
body: fd,
signal: this.controller.signal
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
this.status = 'error';
this.errorCode = typeof body.code === 'string' ? body.code : 'UNKNOWN';
this.errorMessage =
typeof body.message === 'string' ? body.message : `HTTP ${res.status}`;
return;
}
this.recipe = body.recipe as Recipe;
this.status = 'success';
} catch (e) {
if ((e as Error).name === 'AbortError') {
this.status = 'idle';
return;
}
this.status = 'error';
this.errorCode = 'NETWORK';
this.errorMessage = (e as Error).message;
}
}
}

View File

@@ -0,0 +1,225 @@
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
export type SearchSnapshot = {
query: string;
hits: SearchHit[];
webHits: WebHit[];
searchedFor: string | null;
webError: string | null;
localExhausted: boolean;
webPageno: number;
webExhausted: boolean;
};
export type SearchStoreOptions = {
pageSize?: number;
debounceMs?: number;
filterDebounceMs?: number;
minQueryLength?: number;
filterParam?: () => string;
fetchImpl?: typeof fetch;
};
export class SearchStore {
query = $state('');
hits = $state<SearchHit[]>([]);
webHits = $state<WebHit[]>([]);
searching = $state(false);
webSearching = $state(false);
webError = $state<string | null>(null);
searchedFor = $state<string | null>(null);
localExhausted = $state(false);
webPageno = $state(0);
webExhausted = $state(false);
loadingMore = $state(false);
private readonly pageSize: number;
private readonly debounceMs: number;
private readonly filterDebounceMs: number;
private readonly minQueryLength: number;
private readonly filterParam: () => string;
private readonly fetchImpl: typeof fetch;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private skipNextDebounce = false;
constructor(opts: SearchStoreOptions = {}) {
this.pageSize = opts.pageSize ?? 30;
this.debounceMs = opts.debounceMs ?? 300;
this.filterDebounceMs = opts.filterDebounceMs ?? 150;
this.minQueryLength = opts.minQueryLength ?? 4;
this.filterParam = opts.filterParam ?? (() => '');
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
}
runDebounced(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
if (this.skipNextDebounce) {
this.skipNextDebounce = false;
return;
}
const q = this.query.trim();
if (q.length < this.minQueryLength) {
this.resetResults();
return;
}
this.searching = true;
this.webHits = [];
this.webSearching = false;
this.webError = null;
this.debounceTimer = setTimeout(() => {
void this.runSearch(q);
}, this.debounceMs);
}
async runSearch(q: string): Promise<void> {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = null;
this.localExhausted = false;
this.webPageno = 0;
this.webExhausted = false;
try {
const res = await this.fetchImpl(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}`
);
const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return;
this.hits = body.hits;
this.searchedFor = q;
if (this.hits.length < this.pageSize) this.localExhausted = true;
if (this.hits.length === 0) {
await this.runWebSearch(q, 1);
}
} finally {
if (this.query.trim() === q) this.searching = false;
}
}
private async runWebSearch(q: string, pageno: number): Promise<void> {
this.webSearching = true;
try {
const res = await this.fetchImpl(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}`
);
if (this.query.trim() !== q) return;
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { message?: string };
this.webError = err.message ?? `HTTP ${res.status}`;
this.webExhausted = true;
return;
}
const body = (await res.json()) as { hits: WebHit[] };
this.webHits = pageno === 1 ? body.hits : [...this.webHits, ...body.hits];
this.webPageno = pageno;
if (body.hits.length === 0) this.webExhausted = true;
} finally {
if (this.query.trim() === q) this.webSearching = false;
}
}
async loadMore(): Promise<void> {
if (this.loadingMore) return;
const q = this.query.trim();
if (!q) return;
this.loadingMore = true;
try {
if (!this.localExhausted) {
const res = await this.fetchImpl(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}`
);
const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return;
const more = body.hits;
const seen = new Set(this.hits.map((h) => h.id));
const deduped = more.filter((h) => !seen.has(h.id));
this.hits = [...this.hits, ...deduped];
if (more.length < this.pageSize) this.localExhausted = true;
} else if (!this.webExhausted) {
const nextPage = this.webPageno + 1;
const wasEmpty = this.webHits.length === 0;
if (wasEmpty) this.webSearching = true;
try {
const res = await this.fetchImpl(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}`
);
if (this.query.trim() !== q) return;
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { message?: string };
this.webError = err.message ?? `HTTP ${res.status}`;
this.webExhausted = true;
return;
}
const body = (await res.json()) as { hits: WebHit[] };
const more = body.hits;
const seen = new Set(this.webHits.map((h) => h.url));
const deduped = more.filter((h) => !seen.has(h.url));
if (deduped.length === 0) {
this.webExhausted = true;
} else {
this.webHits = [...this.webHits, ...deduped];
this.webPageno = nextPage;
}
} finally {
if (this.query.trim() === q) this.webSearching = false;
}
}
} finally {
this.loadingMore = false;
}
}
reSearch(): void {
const q = this.query.trim();
if (q.length < this.minQueryLength) return;
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.searching = true;
this.webHits = [];
this.webSearching = false;
this.webError = null;
this.debounceTimer = setTimeout(() => void this.runSearch(q), this.filterDebounceMs);
}
reset(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = null;
this.query = '';
this.resetResults();
}
private resetResults(): void {
this.hits = [];
this.webHits = [];
this.searchedFor = null;
this.searching = false;
this.webSearching = false;
this.webError = null;
this.localExhausted = false;
this.webPageno = 0;
this.webExhausted = false;
}
captureSnapshot(): SearchSnapshot {
return {
query: this.query,
hits: this.hits,
webHits: this.webHits,
searchedFor: this.searchedFor,
webError: this.webError,
localExhausted: this.localExhausted,
webPageno: this.webPageno,
webExhausted: this.webExhausted
};
}
restoreSnapshot(s: SearchSnapshot): void {
this.skipNextDebounce = true;
this.query = s.query;
this.hits = s.hits;
this.webHits = s.webHits;
this.searchedFor = s.searchedFor;
this.webError = s.webError;
this.localExhausted = s.localExhausted;
this.webPageno = s.webPageno;
this.webExhausted = s.webExhausted;
}
}

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import { untrack } from 'svelte';
import { ImagePlus, ImageOff } from 'lucide-svelte';
import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online';
type Props = {
recipeId: number;
imagePath: string | null;
onchange: (path: string | null) => void;
};
let { recipeId, imagePath: initial, onchange }: Props = $props();
let imagePath = $state<string | null>(untrack(() => initial));
let uploading = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
const imageSrc = $derived(
imagePath === null
? null
: /^https?:\/\//i.test(imagePath)
? imagePath
: `/images/${imagePath}`
);
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!requireOnline('Der Bild-Upload')) return;
uploading = true;
try {
const fd = new FormData();
fd.append('file', file);
const res = await asyncFetch(
`/api/recipes/${recipeId}/image`,
{ method: 'POST', body: fd },
'Upload fehlgeschlagen'
);
if (!res) return;
const body = await res.json();
imagePath = body.image_path;
onchange(imagePath);
} finally {
uploading = false;
}
}
async function removeImage() {
if (imagePath === null) return;
const ok = await confirmAction({
title: 'Bild entfernen?',
message: 'Das Rezept wird danach ohne Titelbild angezeigt.',
confirmLabel: 'Entfernen',
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Entfernen')) return;
uploading = true;
try {
const res = await asyncFetch(
`/api/recipes/${recipeId}/image`,
{ method: 'DELETE' },
'Entfernen fehlgeschlagen'
);
if (!res) return;
imagePath = null;
onchange(null);
} finally {
uploading = false;
}
}
</script>
<div class="image-row">
<div class="image-preview" class:empty={!imageSrc}>
{#if imageSrc}
<img src={imageSrc} alt="" />
{:else}
<span class="placeholder">Kein Bild</span>
{/if}
</div>
<div class="image-actions">
<button
class="btn"
type="button"
onclick={() => fileInput?.click()}
disabled={uploading}
>
<ImagePlus size={16} strokeWidth={2} />
<span>{imagePath ? 'Bild ersetzen' : 'Bild hochladen'}</span>
</button>
{#if imagePath}
<button class="btn ghost" type="button" onclick={removeImage} disabled={uploading}>
<ImageOff size={16} strokeWidth={2} />
<span>Entfernen</span>
</button>
{/if}
{#if uploading}
<span class="upload-status">Lade …</span>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
class="file-input"
onchange={onFileChosen}
/>
</div>
<p class="image-hint">Max. 10 MB. JPG, PNG, WebP, GIF oder AVIF.</p>
<style>
.image-row {
display: flex;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.image-preview {
width: 160px;
aspect-ratio: 16 / 10;
border-radius: 10px;
overflow: hidden;
background: #eef3ef;
border: 1px solid #e4eae7;
flex-shrink: 0;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-preview.empty {
display: grid;
place-items: center;
color: #999;
font-size: 0.85rem;
}
.image-preview .placeholder {
padding: 0 0.5rem;
text-align: center;
}
.image-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.upload-status {
color: #666;
font-size: 0.9rem;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.image-hint {
margin: 0.6rem 0 0;
color: #888;
font-size: 0.8rem;
}
.btn {
padding: 0.55rem 0.85rem;
border-radius: 10px;
border: 1px solid #cfd9d1;
background: white;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
min-height: 40px;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn.ghost {
color: #666;
}
.btn:disabled {
opacity: 0.6;
cursor: progress;
}
</style>

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { Trash2, ChevronUp, ChevronDown, Plus } from 'lucide-svelte';
import type { DraftIng } from './recipe-editor-types';
type Props = {
ing: DraftIng;
idx: number;
total: number;
onmove: (dir: -1 | 1) => void;
onremove: () => void;
onaddSection: () => void;
onremoveSection: () => void;
};
let { ing, idx, total, onmove, onremove, onaddSection, onremoveSection }: Props = $props();
</script>
{#if ing.section_heading === null}
<li class="section-insert">
<button type="button" class="add-section" onclick={onaddSection}>
<Plus size={12} strokeWidth={2.5} />
<span>Abschnitt hinzufügen</span>
</button>
</li>
{:else}
<li class="section-heading-row">
<input
class="section-heading"
type="text"
bind:value={ing.section_heading}
placeholder='Sektion, z. B. Für den Teig"'
aria-label="Sektionsüberschrift"
/>
<button
type="button"
class="section-remove"
aria-label="Sektion entfernen"
onclick={onremoveSection}
>
<Trash2 size={14} strokeWidth={2} />
</button>
</li>
{/if}
<li class="ing-row">
<div class="move">
<button
class="move-btn"
type="button"
aria-label="Zutat nach oben"
disabled={idx === 0}
onclick={() => onmove(-1)}
>
<ChevronUp size={14} strokeWidth={2.5} />
</button>
<button
class="move-btn"
type="button"
aria-label="Zutat nach unten"
disabled={idx === total - 1}
onclick={() => onmove(1)}
>
<ChevronDown size={14} strokeWidth={2.5} />
</button>
</div>
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
<button class="del" type="button" aria-label="Zutat entfernen" onclick={onremove}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
<style>
.ing-row {
display: grid;
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
gap: 0.35rem;
align-items: center;
}
.move {
display: flex;
flex-direction: column;
gap: 2px;
}
.move-btn {
width: 28px;
height: 20px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 6px;
cursor: pointer;
color: #555;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.move-btn:hover:not(:disabled) {
background: #f4f8f5;
}
.move-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ing-row input {
padding: 0.5rem 0.55rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.9rem;
min-height: 38px;
font-family: inherit;
min-width: 0;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
@media (max-width: 560px) {
.ing-row {
grid-template-columns: 28px 70px 1fr 40px;
grid-template-areas:
'move qty name del'
'move unit unit del'
'note note note note';
}
.ing-row .move {
grid-area: move;
}
.ing-row .qty {
grid-area: qty;
}
.ing-row .unit {
grid-area: unit;
}
.ing-row .name {
grid-area: name;
}
.ing-row .note {
grid-area: note;
}
.ing-row .del {
grid-area: del;
}
}
.section-insert {
display: flex;
justify-content: center;
list-style: none;
margin: -0.2rem 0 0.1rem;
opacity: 0;
transition: opacity 0.15s;
}
/* Parent-UL liegt im RecipeEditor, daher :global(.ing-list). Ohne das
scopt Svelte die Klasse und der Selector matcht zur Laufzeit nicht. */
:global(.ing-list):hover .section-insert,
.section-insert:focus-within {
opacity: 1;
}
.add-section {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.55rem;
border: 1px dashed #cfd9d1;
background: white;
color: #2b6a3d;
border-radius: 999px;
cursor: pointer;
font-size: 0.75rem;
font-family: inherit;
}
.add-section:hover {
background: #f4f8f5;
}
.section-heading-row {
display: grid;
grid-template-columns: 1fr 32px;
gap: 0.35rem;
list-style: none;
margin-top: 0.4rem;
}
.section-heading {
padding: 0.45rem 0.7rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
color: #2b6a3d;
font-family: inherit;
background: #f4f8f5;
}
.section-remove {
width: 32px;
height: 38px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 8px;
color: #666;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.section-remove:hover {
background: #fdf3f3;
border-color: #f1b4b4;
color: #c53030;
}
</style>

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
import { Plus } from 'lucide-svelte';
import type { Recipe, Ingredient, Step } from '$lib/types';
import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online';
import ImageUploadBox from '$lib/components/ImageUploadBox.svelte';
import IngredientRow from '$lib/components/IngredientRow.svelte';
import StepList from '$lib/components/StepList.svelte';
import type { DraftIng, DraftStep } from '$lib/components/recipe-editor-types';
type Props = {
recipe: Recipe;
@@ -27,67 +28,6 @@
let { recipe, saving = false, onsave, oncancel, onimagechange }: Props = $props();
let imagePath = $state<string | null>(untrack(() => recipe.image_path));
let uploading = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
const imageSrc = $derived(
imagePath === null
? null
: /^https?:\/\//i.test(imagePath)
? imagePath
: `/images/${imagePath}`
);
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!requireOnline('Der Bild-Upload')) return;
uploading = true;
try {
const fd = new FormData();
fd.append('file', file);
const res = await asyncFetch(
`/api/recipes/${recipe.id}/image`,
{ method: 'POST', body: fd },
'Upload fehlgeschlagen'
);
if (!res) return;
const body = await res.json();
imagePath = body.image_path;
onimagechange?.(imagePath);
} finally {
uploading = false;
}
}
async function removeImage() {
if (imagePath === null) return;
const ok = await confirmAction({
title: 'Bild entfernen?',
message: 'Das Rezept wird danach ohne Titelbild angezeigt.',
confirmLabel: 'Entfernen',
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Entfernen')) return;
uploading = true;
try {
const res = await asyncFetch(
`/api/recipes/${recipe.id}/image`,
{ method: 'DELETE' },
'Entfernen fehlgeschlagen'
);
if (!res) return;
imagePath = null;
onimagechange?.(null);
} finally {
uploading = false;
}
}
// Form-lokaler Zustand: Initialwerte aus dem Prop snapshotten (untrack),
// damit User-Edits nicht von prop-Updates ueberschrieben werden.
let title = $state(untrack(() => recipe.title));
@@ -97,21 +37,14 @@
let cookMin = $state<number | ''>(untrack(() => recipe.cook_time_min ?? ''));
let totalMin = $state<number | ''>(untrack(() => recipe.total_time_min ?? ''));
type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
type DraftStep = { text: string };
let ingredients = $state<DraftIng[]>(
untrack(() =>
recipe.ingredients.map((i) => ({
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
unit: i.unit ?? '',
name: i.name,
note: i.note ?? ''
note: i.note ?? '',
section_heading: i.section_heading
}))
)
);
@@ -120,7 +53,7 @@
);
function addIngredient() {
ingredients = [...ingredients, { qty: '', unit: '', name: '', note: '' }];
ingredients = [...ingredients, { qty: '', unit: '', name: '', note: '', section_heading: null }];
}
function removeIngredient(idx: number) {
ingredients = ingredients.filter((_, i) => i !== idx);
@@ -132,6 +65,16 @@
[next[idx], next[target]] = [next[target], next[idx]];
ingredients = next;
}
function addSection(idx: number) {
const next = [...ingredients];
next[idx] = { ...next[idx], section_heading: '' };
ingredients = next;
}
function removeSection(idx: number) {
const next = [...ingredients];
next[idx] = { ...next[idx], section_heading: null };
ingredients = next;
}
function addStep() {
steps = [...steps, { text: '' }];
}
@@ -162,13 +105,15 @@
if (qty !== null) rawParts.push(String(qty).replace('.', ','));
if (unit) rawParts.push(unit);
rawParts.push(name);
const heading = i.section_heading === null ? null : (i.section_heading.trim() || null);
return {
position: idx + 1,
quantity: qty,
unit,
name,
note,
raw_text: rawParts.join(' ')
raw_text: rawParts.join(' '),
section_heading: heading
};
});
const cleanedSteps: Step[] = steps
@@ -189,51 +134,20 @@
</script>
<div class="editor">
<section class="block image-block">
<h2>Bild</h2>
<div class="image-row">
<div class="image-preview" class:empty={!imageSrc}>
{#if imageSrc}
<img src={imageSrc} alt="" />
{:else}
<span class="placeholder">Kein Bild</span>
{/if}
</div>
<div class="image-actions">
<button
class="btn"
type="button"
onclick={() => fileInput?.click()}
disabled={uploading}
>
<ImagePlus size={16} strokeWidth={2} />
<span>{imagePath ? 'Bild ersetzen' : 'Bild hochladen'}</span>
</button>
{#if imagePath}
<button
class="btn ghost"
type="button"
onclick={removeImage}
disabled={uploading}
>
<ImageOff size={16} strokeWidth={2} />
<span>Entfernen</span>
</button>
{/if}
{#if uploading}
<span class="upload-status">Lade …</span>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
class="file-input"
onchange={onFileChosen}
{#if recipe.id !== null}
<section class="block">
<h2>Bild</h2>
<ImageUploadBox
recipeId={recipe.id}
imagePath={recipe.image_path}
onchange={(p) => onimagechange?.(p)}
/>
</div>
<p class="image-hint">Max. 10 MB. JPG, PNG, WebP, GIF oder AVIF.</p>
</section>
</section>
{:else}
<section class="block info">
<p class="hint">Bild kannst du nach dem Speichern hinzufügen.</p>
</section>
{/if}
<div class="meta">
<label class="field">
@@ -273,35 +187,15 @@
<h2>Zutaten</h2>
<ul class="ing-list">
{#each ingredients as ing, idx (idx)}
<li class="ing-row">
<div class="move">
<button
class="move-btn"
type="button"
aria-label="Zutat nach oben"
disabled={idx === 0}
onclick={() => moveIngredient(idx, -1)}
>
<ChevronUp size={14} strokeWidth={2.5} />
</button>
<button
class="move-btn"
type="button"
aria-label="Zutat nach unten"
disabled={idx === ingredients.length - 1}
onclick={() => moveIngredient(idx, 1)}
>
<ChevronDown size={14} strokeWidth={2.5} />
</button>
</div>
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
<button class="del" type="button" aria-label="Zutat entfernen" onclick={() => removeIngredient(idx)}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
<IngredientRow
{ing}
{idx}
total={ingredients.length}
onmove={(dir) => moveIngredient(idx, dir)}
onremove={() => removeIngredient(idx)}
onaddSection={() => addSection(idx)}
onremoveSection={() => removeSection(idx)}
/>
{/each}
</ul>
<button class="add" type="button" onclick={addIngredient}>
@@ -312,25 +206,7 @@
<section class="block">
<h2>Zubereitung</h2>
<ol class="step-list">
{#each steps as step, idx (idx)}
<li class="step-row">
<span class="num">{idx + 1}</span>
<textarea
bind:value={step.text}
rows="3"
placeholder="Schritt beschreiben …"
></textarea>
<button class="del" type="button" aria-label="Schritt entfernen" onclick={() => removeStep(idx)}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
{/each}
</ol>
<button class="add" type="button" onclick={addStep}>
<Plus size={16} strokeWidth={2} />
<span>Schritt hinzufügen</span>
</button>
<StepList {steps} onadd={addStep} onremove={removeStep} />
</section>
<div class="foot">
@@ -396,74 +272,21 @@
border-radius: 12px;
padding: 1rem;
}
.image-row {
display: flex;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.image-preview {
width: 160px;
aspect-ratio: 16 / 10;
border-radius: 10px;
overflow: hidden;
background: #eef3ef;
border: 1px solid #e4eae7;
flex-shrink: 0;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-preview.empty {
display: grid;
place-items: center;
color: #999;
font-size: 0.85rem;
}
.image-preview .placeholder {
padding: 0 0.5rem;
text-align: center;
}
.image-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.image-actions .btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.85rem;
min-height: 40px;
font-size: 0.9rem;
}
.upload-status {
color: #666;
font-size: 0.9rem;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.image-hint {
margin: 0.6rem 0 0;
color: #888;
font-size: 0.8rem;
}
.block h2 {
font-size: 1.05rem;
margin: 0 0 0.75rem;
color: #2b6a3d;
}
.ing-list,
.step-list {
.block.info {
background: #f6faf7;
border: 1px dashed #cfd9d1;
}
.hint {
color: #666;
margin: 0;
font-size: 0.9rem;
}
.ing-list {
list-style: none;
padding: 0;
margin: 0 0 0.6rem;
@@ -471,88 +294,6 @@
flex-direction: column;
gap: 0.4rem;
}
.ing-row {
display: grid;
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
gap: 0.35rem;
align-items: center;
}
.move {
display: flex;
flex-direction: column;
gap: 2px;
}
.move-btn {
width: 28px;
height: 20px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 6px;
cursor: pointer;
color: #555;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.move-btn:hover:not(:disabled) {
background: #f4f8f5;
}
.move-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ing-row input {
padding: 0.5rem 0.55rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.9rem;
min-height: 38px;
font-family: inherit;
min-width: 0;
}
.step-row {
display: grid;
grid-template-columns: 32px 1fr 40px;
gap: 0.5rem;
align-items: start;
}
.num {
width: 32px;
height: 32px;
background: #2b6a3d;
color: white;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 600;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.step-row textarea {
padding: 0.55rem 0.7rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
min-height: 70px;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
.add {
display: inline-flex;
align-items: center;
@@ -598,31 +339,4 @@
opacity: 0.6;
cursor: progress;
}
@media (max-width: 560px) {
.ing-row {
grid-template-columns: 28px 70px 1fr 40px;
grid-template-areas:
'move qty name del'
'move unit unit del'
'note note note note';
}
.ing-row .move {
grid-area: move;
}
.ing-row .qty {
grid-area: qty;
}
.ing-row .unit {
grid-area: unit;
}
.ing-row .name {
grid-area: name;
}
.ing-row .note {
grid-area: note;
}
.ing-row .del {
grid-area: del;
}
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { scaleIngredients } from '$lib/recipes/scaler';
import type { Recipe } from '$lib/types';
import TimeDisplay from '$lib/components/TimeDisplay.svelte';
type Props = {
recipe: Recipe;
@@ -41,15 +42,6 @@
if (Number.isInteger(q)) return String(q);
return q.toLocaleString('de-DE', { maximumFractionDigits: 2 });
}
function timeSummary(): string {
const parts: string[] = [];
if (recipe.prep_time_min) parts.push(`Vorb. ${recipe.prep_time_min} min`);
if (recipe.cook_time_min) parts.push(`Kochen ${recipe.cook_time_min} min`);
if (!recipe.prep_time_min && !recipe.cook_time_min && recipe.total_time_min)
parts.push(`Gesamt ${recipe.total_time_min} min`);
return parts.join(' · ');
}
</script>
{#if banner}
@@ -79,9 +71,11 @@
{/each}
{/if}
</div>
{#if timeSummary()}
<p class="times">{timeSummary()}</p>
{/if}
<TimeDisplay
prepTimeMin={recipe.prep_time_min}
cookTimeMin={recipe.cook_time_min}
totalTimeMin={recipe.total_time_min}
/>
{#if recipe.source_url}
<p class="src">
Quelle: <a href={recipe.source_url} target="_blank" rel="noopener">{recipe.source_domain}</a>
@@ -133,6 +127,9 @@
</div>
<ul class="ing-list">
{#each scaled as ing, i (i)}
{#if ing.section_heading && ing.section_heading.trim()}
<li class="section-heading">{ing.section_heading}</li>
{/if}
<li>
{#if ing.quantity !== null || ing.unit}
<span class="qty">
@@ -212,11 +209,6 @@
font-size: 0.8rem;
color: #888;
}
.times {
margin: 0 0 0.25rem;
color: #666;
font-size: 0.9rem;
}
.src {
margin: 0;
font-size: 0.85rem;
@@ -292,6 +284,19 @@
padding: 0;
margin: 0;
}
.ing-list .section-heading {
list-style: none;
font-weight: 700;
color: #2b6a3d;
font-size: 1.2rem;
margin-top: 1.1rem;
margin-bottom: 0.3rem;
padding: 0.2rem 0;
border-bottom: 1px solid #e4eae7;
}
.ing-list .section-heading:first-child {
margin-top: 0;
}
.ing-list li {
display: flex;
gap: 0.75rem;

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { Plus, Trash2 } from 'lucide-svelte';
import type { DraftStep } from './recipe-editor-types';
type Props = {
steps: DraftStep[];
onadd: () => void;
onremove: (idx: number) => void;
};
let { steps, onadd, onremove }: Props = $props();
</script>
<ol class="step-list">
{#each steps as step, idx (idx)}
<li class="step-row">
<span class="num">{idx + 1}</span>
<textarea
bind:value={step.text}
rows="3"
placeholder="Schritt beschreiben …"
></textarea>
<button class="del" type="button" aria-label="Schritt entfernen" onclick={() => onremove(idx)}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
{/each}
</ol>
<button class="add" type="button" onclick={onadd}>
<Plus size={16} strokeWidth={2} />
<span>Schritt hinzufügen</span>
</button>
<style>
.step-list {
list-style: none;
padding: 0;
margin: 0 0 0.6rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.step-row {
display: grid;
grid-template-columns: 32px 1fr 40px;
gap: 0.5rem;
align-items: start;
}
.num {
width: 32px;
height: 32px;
background: #2b6a3d;
color: white;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 600;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.step-row textarea {
padding: 0.55rem 0.7rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
min-height: 70px;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
.add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 0.9rem;
border: 1px dashed #cfd9d1;
background: white;
color: #2b6a3d;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.add:hover {
background: #f4f8f5;
}
</style>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
type Props = {
prepTimeMin: number | null;
cookTimeMin: number | null;
totalTimeMin: number | null;
};
let { prepTimeMin, cookTimeMin, totalTimeMin }: Props = $props();
const summary = $derived.by(() => {
const parts: string[] = [];
if (prepTimeMin) parts.push(`Vorb. ${prepTimeMin} min`);
if (cookTimeMin) parts.push(`Kochen ${cookTimeMin} min`);
if (!prepTimeMin && !cookTimeMin && totalTimeMin)
parts.push(`Gesamt ${totalTimeMin} min`);
return parts.join(' · ');
});
</script>
{#if summary}
<p class="times">{summary}</p>
{/if}
<style>
.times {
margin: 0 0 0.25rem;
color: #666;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,9 @@
export type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
section_heading: string | null;
};
export type DraftStep = { text: string };

View File

@@ -0,0 +1,56 @@
export const DESCRIPTION_PHRASES: readonly string[] = [
'Mit dem Zauberstab aus dem Kochbuch geholt.',
'Foto-Magie frisch aus dem Ofen.',
'Aus dem Bild herbeigezaubert.',
'Ein Klick, ein Foto, fertig.',
'Knipsen statt Abtippen.',
'Von der Buchseite direkt in die Pfanne.',
'Die Kamera hat mitgelesen.',
'Abrakadabra — Rezept da.',
'Per Linse in die Küche teleportiert.',
'Von Oma abfotografiert, von der KI entziffert.',
'Frisch aus dem Bilderrahmen.',
'Klick, zisch, Rezept.',
'Das Foto wurde überredet, sich zu verraten.',
'Schnappschuss zur Schüssel.',
'Einmal lesen lassen, schon da.',
'Keine Hand hat dieses Rezept abgetippt.',
'Vom Bild in die Bratpfanne.',
'Papier ist geduldig, das Foto war es auch.',
'Eine Seite, ein Foto, ein Rezept.',
'Die KI hat drübergeschielt.',
'Handschriftlich entziffert — oder zumindest versucht.',
'Aus der Linse in die Liste.',
'Vom Küchentisch zur Kachel.',
'Knips und weg — zumindest der Zettel.',
'Das Bild hat geredet.',
'Keine Tippfehler, nur Sehfehler.',
'Per Foto eingebürgert.',
'Rezept-Übersetzung aus dem Bild.',
'Die Seite hat sich verraten.',
'Blitzlicht und dann Gulasch.',
'Ein Augenzwinkern der Kamera genügte.',
'Geknipst, gelesen, gespeichert.',
'Fotografische Gedächtnishilfe.',
'Aus der Schublade ans Licht.',
'Das Rezept stand schon da — wir haben nur hingeguckt.',
'Zaubertrick mit Kamera.',
'Vom Papier befreit.',
'Ein Foto sagt mehr als tausend Zutatenlisten.',
'Eingescannt, rausgelesen, reingeschrieben.',
'Die Kamera als Küchenhilfe.',
'Handy hoch, Rezept runter.',
'Aus dem Kochbuch gebeamt.',
'Ein scharfes Foto, ein klares Rezept.',
'Vom Regal zur App in einem Schritt.',
'Aus dem Bild geschöpft wie Suppe aus dem Topf.',
'Optisch erfasst, digital serviert.',
'Das Kleingedruckte hat die KI gelesen.',
'Vom Kladdenzettel in die Datenbank.',
'Kurz gezückt, schon gekocht.',
'Kein Schreibkrampf, nur ein Klick.'
];
export function pickRandomPhrase(): string {
return DESCRIPTION_PHRASES[Math.floor(Math.random() * DESCRIPTION_PHRASES.length)];
}

View File

@@ -0,0 +1,150 @@
import { GoogleGenerativeAI } from '@google/generative-ai';
import { env } from '$env/dynamic/private';
import {
RECIPE_EXTRACTION_SYSTEM_PROMPT,
GEMINI_RESPONSE_SCHEMA,
extractionResponseSchema,
type ExtractionResponse
} from './recipe-extraction-prompt';
export type GeminiErrorCode =
| 'AI_NOT_CONFIGURED'
| 'AI_RATE_LIMITED'
| 'AI_TIMEOUT'
| 'AI_FAILED';
export class GeminiError extends Error {
constructor(
public readonly code: GeminiErrorCode,
message: string
) {
super(message);
this.name = 'GeminiError';
}
}
function getStatus(err: unknown): number | undefined {
if (err && typeof err === 'object' && 'status' in err) {
const s = (err as { status?: unknown }).status;
if (typeof s === 'number') return s;
}
return undefined;
}
function getCfg(): { apiKey: string; model: string; timeoutMs: number } {
const apiKey = env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY ?? '';
const model =
env.GEMINI_MODEL ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';
const rawTimeout =
env.GEMINI_TIMEOUT_MS ?? process.env.GEMINI_TIMEOUT_MS ?? '20000';
const timeoutMs = Number(rawTimeout) || 20000;
return { apiKey, model, timeoutMs };
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new GeminiError('AI_TIMEOUT', `Gemini timeout after ${ms} ms`)),
ms
);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
}
);
});
}
async function callGemini(
imageBuffer: Buffer,
mimeType: string,
appendUserNote?: string
): Promise<ExtractionResponse> {
const { apiKey, model: modelId, timeoutMs } = getCfg();
if (!apiKey) {
throw new GeminiError('AI_NOT_CONFIGURED', 'GEMINI_API_KEY is not set');
}
const client = new GoogleGenerativeAI(apiKey);
const model = client.getGenerativeModel({
model: modelId,
systemInstruction: RECIPE_EXTRACTION_SYSTEM_PROMPT,
generationConfig: {
temperature: 0.1,
responseMimeType: 'application/json',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
responseSchema: GEMINI_RESPONSE_SCHEMA as any
}
});
const parts: Array<
{ inlineData: { data: string; mimeType: string } } | { text: string }
> = [{ inlineData: { data: imageBuffer.toString('base64'), mimeType } }];
if (appendUserNote) parts.push({ text: appendUserNote });
const result = await withTimeout(
model.generateContent({ contents: [{ role: 'user', parts }] }),
timeoutMs
);
const text = result.response.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new GeminiError('AI_FAILED', 'Gemini returned non-JSON output');
}
const validated = extractionResponseSchema.safeParse(parsed);
if (!validated.success) {
throw new GeminiError(
'AI_FAILED',
`Schema validation failed: ${validated.error.message}`
);
}
return validated.data;
}
// Public entry: one retry on recoverable failures (5xx or schema-invalid),
// no retry on 429, AI_TIMEOUT, or config errors.
export async function extractRecipeFromImage(
imageBuffer: Buffer,
mimeType: string
): Promise<ExtractionResponse> {
try {
return await callGemini(imageBuffer, mimeType);
} catch (e) {
if (e instanceof GeminiError && e.code === 'AI_NOT_CONFIGURED') throw e;
if (e instanceof GeminiError && e.code === 'AI_TIMEOUT') throw e;
const status = getStatus(e);
if (status === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit');
const recoverable =
(e instanceof GeminiError && e.code === 'AI_FAILED') ||
(status !== undefined && status >= 500);
if (!recoverable) {
throw e instanceof GeminiError
? e
: new GeminiError('AI_FAILED', String(e));
}
await new Promise((r) => setTimeout(r, 500));
try {
return await callGemini(
imageBuffer,
mimeType,
'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
);
} catch (retryErr) {
if (retryErr instanceof GeminiError) throw retryErr;
const retryStatus = getStatus(retryErr);
if (retryStatus === 429)
throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry');
throw new GeminiError('AI_FAILED', String(retryErr));
}
}
}

View File

@@ -0,0 +1,54 @@
import type SharpType from 'sharp';
import { createRequire } from 'node:module';
const MAX_EDGE = 1600;
const JPEG_QUALITY = 85;
export type PreprocessedImage = {
buffer: Buffer;
mimeType: 'image/jpeg';
};
// sharp per Node-Runtime-require laden, nicht via ES-Import: adapter-node
// bundelt ES-Imports (auch dynamische, auch mit @vite-ignore) ins Server-
// Bundle, was sharp's internes dynamic-require fuer die Plattform-.node-Binary
// zerstoert. createRequire + require() ist pure Node-Runtime-Logik, die
// Rollup nicht anfasst -- sharp wird regulaer aus node_modules geladen.
const nodeRequire = createRequire(import.meta.url);
let sharpModule: typeof SharpType | null = null;
function loadSharp(): typeof SharpType {
if (!sharpModule) {
sharpModule = nodeRequire('sharp') as typeof SharpType;
}
return sharpModule;
}
// Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen.
// sharp liest HEIC/HEIF transparent, wenn libheif im libvips-Build enthalten ist
// (in Alpine's vips-dev + in den offiziellen sharp-Prebuilds).
export async function preprocessImage(input: Buffer): Promise<PreprocessedImage> {
const sharp = loadSharp();
const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation
const meta = await pipeline.metadata();
if (!meta.width || !meta.height) {
throw new Error('Unable to read image dimensions');
}
const longEdge = Math.max(meta.width, meta.height);
const resized =
longEdge > MAX_EDGE
? pipeline.resize({
width: meta.width >= meta.height ? MAX_EDGE : undefined,
height: meta.height > meta.width ? MAX_EDGE : undefined,
withoutEnlargement: true
})
: pipeline;
// Default-Verhalten seit sharp 0.33: alle Metadata (EXIF/IPTC/XMP) werden
// gestripped. Nur `.keepMetadata()`/`.keepExif()` würde sie erhalten.
const buffer = await resized
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
.toBuffer();
return { buffer, mimeType: 'image/jpeg' };
}

View File

@@ -0,0 +1,21 @@
export type RateLimiter = { check: (key: string) => boolean };
export function createRateLimiter(opts: {
windowMs: number;
max: number;
}): RateLimiter {
const store = new Map<string, { count: number; resetAt: number }>();
return {
check(key: string): boolean {
const now = Date.now();
const entry = store.get(key);
if (!entry || entry.resetAt <= now) {
store.set(key, { count: 1, resetAt: now + opts.windowMs });
return true;
}
if (entry.count >= opts.max) return false;
entry.count += 1;
return true;
}
};
}

View File

@@ -0,0 +1,85 @@
import { z } from 'zod';
import { SchemaType } from '@google/generative-ai';
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent.
Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück.
Regeln:
- Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array).
- Zutaten: quantity als Zahl (Bruchteile wie ½, ¼, 1 ½ als Dezimalzahl 0.5, 0.25, 1.5), unit separat
(g, ml, l, kg, EL, TL, Stück, Prise, Msp, …).
- Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt.
- Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60.
- Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst.
- Denke dir NICHTS dazu aus. Was nicht auf dem Bild steht, ist null.
- Antworte ausschließlich im vorgegebenen JSON-Schema. Kein Markdown, kein Prosa-Text.`;
// Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent
// übergeben; Gemini respektiert die Struktur und liefert valides JSON.
export const GEMINI_RESPONSE_SCHEMA = {
type: SchemaType.OBJECT,
properties: {
title: { type: SchemaType.STRING, nullable: false },
servings_default: { type: SchemaType.INTEGER, nullable: true },
servings_unit: { type: SchemaType.STRING, nullable: true },
prep_time_min: { type: SchemaType.INTEGER, nullable: true },
cook_time_min: { type: SchemaType.INTEGER, nullable: true },
total_time_min: { type: SchemaType.INTEGER, nullable: true },
ingredients: {
type: SchemaType.ARRAY,
items: {
type: SchemaType.OBJECT,
properties: {
quantity: { type: SchemaType.NUMBER, nullable: true },
unit: { type: SchemaType.STRING, nullable: true },
name: { type: SchemaType.STRING, nullable: false },
note: { type: SchemaType.STRING, nullable: true }
},
required: ['name']
}
},
steps: {
type: SchemaType.ARRAY,
items: {
type: SchemaType.OBJECT,
properties: {
text: { type: SchemaType.STRING, nullable: false }
},
required: ['text']
}
}
},
required: ['title', 'ingredients', 'steps']
} as const;
// Zod-Spiegel des Schemas. .strict() verhindert, dass Gemini zusätzliche Keys
// unbemerkt durchschmuggelt.
const ingredientSchema = z
.object({
quantity: z.number().nullable(),
unit: z.string().max(30).nullable(),
name: z.string().min(1).max(200),
note: z.string().max(300).nullable()
})
.strict();
const stepSchema = z
.object({
text: z.string().min(1).max(4000)
})
.strict();
export const extractionResponseSchema = z
.object({
title: z.string().min(1).max(200),
servings_default: z.number().int().nonnegative().nullable(),
servings_unit: z.string().max(30).nullable(),
prep_time_min: z.number().int().nonnegative().nullable(),
cook_time_min: z.number().int().nonnegative().nullable(),
total_time_min: z.number().int().nonnegative().nullable(),
ingredients: z.array(ingredientSchema),
steps: z.array(stepSchema)
})
.strict();
export type ExtractionResponse = z.infer<typeof extractionResponseSchema>;

View File

@@ -0,0 +1,7 @@
-- Nullable-Spalte fuer optionale Sektionsueberschriften bei Zutaten. User
-- soll im Editor gruppieren koennen ("Fuer den Teig", "Fuer die Fuellung").
-- Rendering-Regel: Ist section_heading gesetzt (nicht NULL, nicht leer),
-- startet an dieser Zeile eine neue Sektion mit diesem Titel; alle folgenden
-- Zutaten gehoeren dazu, bis die naechste Zeile wieder eine Ueberschrift hat.
-- Ordnung bleibt die bestehende position-Spalte.
ALTER TABLE ingredient ADD COLUMN section_heading TEXT;

View File

@@ -105,16 +105,16 @@ export function parseIngredient(raw: string, position = 0): Ingredient {
if (tail.length > 0) {
const quantity = clampQuantity(UNICODE_FRACTION_MAP[firstChar]);
const { unit, name } = splitUnitAndName(tail);
return { position, quantity, unit, name, note, raw_text: rawText };
return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null };
}
}
const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
const qtyMatch = qtyPattern.exec(working);
if (!qtyMatch) {
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText, section_heading: null };
}
const quantity = clampQuantity(parseQuantity(qtyMatch[1]));
const { unit, name } = splitUnitAndName(qtyMatch[2]);
return { position, quantity, unit, name, note, raw_text: rawText };
return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null };
}

View File

@@ -64,11 +64,11 @@ export function insertRecipe(db: Database.Database, recipe: Recipe): number {
const id = Number(info.lastInsertRowid);
const insIng = db.prepare(
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text)
VALUES (?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const ing of recipe.ingredients) {
insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text);
insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
}
const insStep = db.prepare(
@@ -104,7 +104,7 @@ export function getRecipeById(db: Database.Database, id: number): Recipe | null
const ingredients = db
.prepare(
`SELECT position, quantity, unit, name, note, raw_text
`SELECT position, quantity, unit, name, note, raw_text, section_heading
FROM ingredient WHERE recipe_id = ? ORDER BY position`
)
.all(id) as Ingredient[];
@@ -215,11 +215,11 @@ export function replaceIngredients(
const tx = db.transaction(() => {
db.prepare('DELETE FROM ingredient WHERE recipe_id = ?').run(recipeId);
const ins = db.prepare(
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text)
VALUES (?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const ing of ingredients) {
ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text);
ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
}
refreshFts(db, recipeId);
});

View File

@@ -1,4 +1,4 @@
export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only';
export type CacheStrategy = 'shell' | 'network-first' | 'images' | 'network-only';
type RequestShape = { url: string; method: string };
@@ -16,6 +16,7 @@ export function resolveStrategy(req: RequestShape): CacheStrategy {
if (
path === '/api/recipes/import' ||
path === '/api/recipes/preview' ||
path === '/api/recipes/extract-from-photo' ||
path.startsWith('/api/recipes/search/web')
) {
return 'network-only';
@@ -37,6 +38,7 @@ export function resolveStrategy(req: RequestShape): CacheStrategy {
return 'shell';
}
// Everything else: recipe pages, API reads, lists — all SWR.
return 'swr';
// Everything else: recipe pages, API reads, lists — network-first with
// timeout fallback to cache (handled in service-worker.ts).
return 'network-first';
}

View File

@@ -5,6 +5,7 @@ export type Ingredient = {
name: string;
note: string | null;
raw_text: string;
section_heading: string | null;
};
export type Step = {

View File

@@ -0,0 +1,9 @@
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
export const load: LayoutServerLoad = () => {
return {
version: env.KOCHWAS_TAG ?? 'dev',
geminiConfigured: Boolean(env.GEMINI_API_KEY)
};
};

View File

@@ -2,7 +2,15 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto, afterNavigate } from '$app/navigation';
import { Settings, CookingPot, Utensils, Menu, BookOpen, ArrowLeft } from 'lucide-svelte';
import {
Settings,
CookingPot,
Utensils,
Menu,
BookOpen,
ArrowLeft,
Camera
} from 'lucide-svelte';
import { profileStore } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte';
import { pwaStore } from '$lib/client/pwa.svelte';
@@ -17,26 +25,20 @@
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';
import { SearchStore } from '$lib/client/search.svelte';
let { children } = $props();
let { data, children } = $props();
const NAV_PAGE_SIZE = 30;
const navStore = new SearchStore({
pageSize: 30,
filterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
});
let navQuery = $state('');
let navHits = $state<SearchHit[]>([]);
let navWebHits = $state<WebHit[]>([]);
let navSearching = $state(false);
let navWebSearching = $state(false);
let navWebError = $state<string | null>(null);
let navOpen = $state(false);
let navLocalExhausted = $state(false);
let navWebPageno = $state(0);
let navWebExhausted = $state(false);
let navLoadingMore = $state(false);
let navContainer: HTMLElement | undefined = $state();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let menuOpen = $state(false);
let menuContainer: HTMLElement | undefined = $state();
@@ -44,123 +46,21 @@
$page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview'
);
function filterParam(): string {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
$effect(() => {
const q = navQuery.trim();
if (debounceTimer) clearTimeout(debounceTimer);
if (q.length <= 3) {
navHits = [];
navWebHits = [];
navSearching = false;
navWebSearching = false;
navWebError = null;
navOpen = false;
navLocalExhausted = false;
navWebPageno = 0;
navWebExhausted = false;
return;
}
navSearching = true;
navWebHits = [];
navWebSearching = false;
navWebError = null;
navOpen = true;
navLocalExhausted = false;
navWebPageno = 0;
navWebExhausted = false;
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}${filterParam()}`
);
const body = await res.json();
if (navQuery.trim() !== q) return;
navHits = body.hits;
if (navHits.length < NAV_PAGE_SIZE) navLocalExhausted = true;
if (navHits.length === 0) {
navWebSearching = true;
try {
const wres = await fetch(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}`
);
if (navQuery.trim() !== q) return;
if (!wres.ok) {
const err = await wres.json().catch(() => ({}));
navWebError = err.message ?? `HTTP ${wres.status}`;
navWebExhausted = true;
} else {
const wbody = await wres.json();
navWebHits = wbody.hits;
navWebPageno = 1;
if (navWebHits.length === 0) navWebExhausted = true;
}
} finally {
if (navQuery.trim() === q) navWebSearching = false;
}
}
} finally {
if (navQuery.trim() === q) navSearching = false;
}
}, 300);
// Bare reads register the reactive deps; then kick the store.
const q = navStore.query;
navStore.runDebounced();
// navOpen follows query length: open while typing, close when cleared.
navOpen = q.trim().length > 3;
});
async function loadMoreNav() {
if (navLoadingMore) return;
const q = navQuery.trim();
if (!q) return;
navLoadingMore = true;
try {
if (!navLocalExhausted) {
const res = await fetch(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}&offset=${navHits.length}${filterParam()}`
);
const body = await res.json();
if (navQuery.trim() !== q) return;
const more = body.hits as SearchHit[];
const seen = new Set(navHits.map((h) => h.id));
const deduped = more.filter((h) => !seen.has(h.id));
navHits = [...navHits, ...deduped];
if (more.length < NAV_PAGE_SIZE) navLocalExhausted = true;
} else if (!navWebExhausted) {
const nextPage = navWebPageno + 1;
navWebSearching = navWebHits.length === 0;
try {
const wres = await fetch(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}`
);
if (navQuery.trim() !== q) return;
if (!wres.ok) {
const err = await wres.json().catch(() => ({}));
navWebError = err.message ?? `HTTP ${wres.status}`;
navWebExhausted = true;
return;
}
const wbody = await wres.json();
const more = wbody.hits as WebHit[];
const seen = new Set(navWebHits.map((h) => h.url));
const deduped = more.filter((h) => !seen.has(h.url));
if (deduped.length === 0) {
navWebExhausted = true;
} else {
navWebHits = [...navWebHits, ...deduped];
navWebPageno = nextPage;
}
} finally {
if (navQuery.trim() === q) navWebSearching = false;
}
}
} finally {
navLoadingMore = false;
}
function loadMoreNav() {
return navStore.loadMore();
}
function submitNav(e: SubmitEvent) {
e.preventDefault();
const q = navQuery.trim();
const q = navStore.query.trim();
if (!q) return;
navOpen = false;
void goto(`/?q=${encodeURIComponent(q)}`);
@@ -184,15 +84,11 @@
function pickHit() {
navOpen = false;
navQuery = '';
navHits = [];
navWebHits = [];
navStore.reset();
}
afterNavigate(() => {
navQuery = '';
navHits = [];
navWebHits = [];
navStore.reset();
navOpen = false;
menuOpen = false;
// Badge nach jeder Client-Navigation frisch halten — sonst kann er
@@ -227,7 +123,10 @@
<header class="bar">
<div class="bar-inner">
{#if $page.url.pathname === '/'}
<a href="/" class="brand">Kochwas</a>
<div class="brand-stack">
<a href="/" class="brand">Kochwas</a>
<span class="version" title="Deployment-Tag">{data.version}</span>
</div>
{:else}
<a href="/" class="home-back" aria-label="Zurück zur Startseite">
<ArrowLeft size={22} strokeWidth={2} />
@@ -239,9 +138,9 @@
<SearchFilter inline />
<input
type="search"
bind:value={navQuery}
bind:value={navStore.query}
onfocus={() => {
if (navHits.length > 0 || navQuery.trim().length > 3) navOpen = true;
if (navStore.hits.length > 0 || navStore.query.trim().length > 3) navOpen = true;
}}
placeholder="Rezept suchen…"
autocomplete="off"
@@ -251,12 +150,12 @@
</form>
{#if navOpen}
<div class="dropdown" role="listbox">
{#if navSearching && navHits.length === 0 && navWebHits.length === 0}
{#if navStore.searching && navStore.hits.length === 0 && navStore.webHits.length === 0}
<SearchLoader scope="local" size="sm" />
{:else}
{#if navHits.length > 0}
{#if navStore.hits.length > 0}
<ul class="dd-list">
{#each navHits as r (r.id)}
{#each navStore.hits as r (r.id)}
<li>
<a
href={`/recipes/${r.id}`}
@@ -282,14 +181,14 @@
</ul>
{/if}
{#if navWebHits.length > 0}
{#if navHits.length > 0}
{#if navStore.webHits.length > 0}
{#if navStore.hits.length > 0}
<p class="dd-section">Aus dem Internet</p>
{:else}
<p class="dd-section">Keine lokalen Rezepte aus dem Internet:</p>
{/if}
<ul class="dd-list">
{#each navWebHits as w (w.url)}
{#each navStore.webHits as w (w.url)}
<li>
<a
href={`/preview?url=${encodeURIComponent(w.url)}`}
@@ -313,23 +212,23 @@
</ul>
{/if}
{#if navWebSearching}
{#if navStore.webSearching}
<SearchLoader scope="web" size="sm" />
{:else if navWebError && navWebHits.length === 0}
{:else if navStore.webError && navStore.webHits.length === 0}
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
{:else if navHits.length === 0 && navWebHits.length === 0 && !navSearching}
{:else if navStore.hits.length === 0 && navStore.webHits.length === 0 && !navStore.searching}
<p class="dd-status">Auch im Internet nichts gefunden.</p>
{/if}
{#if !(navLocalExhausted && navWebExhausted) && (navHits.length > 0 || navWebHits.length > 0)}
{#if !(navStore.localExhausted && navStore.webExhausted) && (navStore.hits.length > 0 || navStore.webHits.length > 0)}
<button
class="dd-web"
type="button"
onclick={loadMoreNav}
disabled={navLoadingMore || navWebSearching}
disabled={navStore.loadingMore || navStore.webSearching}
>
<span
>{navLoadingMore || navWebSearching
>{navStore.loadingMore || navStore.webSearching
? 'Lade …'
: '+ weitere Ergebnisse'}</span
>
@@ -341,6 +240,22 @@
</div>
{/if}
<div class="bar-right">
{#if data.geminiConfigured}
<a
href={network.online ? '/new/from-photo' : ''}
class="nav-link magic-link"
class:disabled={!network.online}
aria-label="Rezept aus Foto erstellen"
title={network.online
? 'Rezept aus Foto erstellen'
: 'Offline — braucht Internet'}
onclick={(e) => {
if (!network.online) e.preventDefault();
}}
>
<Camera size={20} strokeWidth={2} />
</a>
{/if}
<a
href="/wishlist"
class="nav-link wishlist-link"
@@ -419,6 +334,13 @@
padding: 0.6rem 1rem;
position: relative;
}
.brand-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1;
flex-shrink: 0;
}
.brand {
font-size: 1.15rem;
font-weight: 700;
@@ -426,6 +348,13 @@
color: #2b6a3d;
flex-shrink: 0;
}
.version {
margin-top: 2px;
font-size: 0.65rem;
color: #9aa8a0;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.home-back {
display: inline-flex;
align-items: center;
@@ -632,6 +561,11 @@
.nav-link:hover {
background: #f4f8f5;
}
.nav-link.disabled {
color: #999;
pointer-events: none;
cursor: not-allowed;
}
.badge {
position: absolute;
top: -2px;
@@ -656,7 +590,7 @@
}
@media (max-width: 520px) {
/* App-Icon auf engen Screens komplett aus — die Suche bekommt den Platz. */
.brand {
.brand-stack {
display: none;
}
.nav-link {

View File

@@ -4,32 +4,27 @@
import { CookingPot, X } from 'lucide-svelte';
import type { Snapshot } from './$types';
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
import { randomQuote } from '$lib/quotes';
import SearchLoader from '$lib/components/SearchLoader.svelte';
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';
import { SearchStore, type SearchSnapshot } from '$lib/client/search.svelte';
const LOCAL_PAGE = 30;
let query = $state('');
const store = new SearchStore({
pageSize: LOCAL_PAGE,
filterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
});
let quote = $state('');
let recent = $state<SearchHit[]>([]);
let favorites = $state<SearchHit[]>([]);
let hits = $state<SearchHit[]>([]);
let webHits = $state<WebHit[]>([]);
let searching = $state(false);
let webSearching = $state(false);
let webError = $state<string | null>(null);
let searchedFor = $state<string | null>(null);
let localExhausted = $state(false);
let webPageno = $state(0);
let webExhausted = $state(false);
let loadingMore = $state(false);
let skipNextSearch = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const ALL_PAGE = 10;
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
@@ -47,39 +42,9 @@
let allChips: HTMLElement | undefined = $state();
let allObserver: IntersectionObserver | null = null;
type SearchSnapshot = {
query: string;
hits: SearchHit[];
webHits: WebHit[];
searchedFor: string | null;
webError: string | null;
localExhausted: boolean;
webPageno: number;
webExhausted: boolean;
};
export const snapshot: Snapshot<SearchSnapshot> = {
capture: () => ({
query,
hits,
webHits,
searchedFor,
webError,
localExhausted,
webPageno,
webExhausted
}),
restore: (v) => {
query = v.query;
hits = v.hits;
webHits = v.webHits;
searchedFor = v.searchedFor;
webError = v.webError;
localExhausted = v.localExhausted;
webPageno = v.webPageno;
webExhausted = v.webExhausted;
skipNextSearch = true;
}
capture: () => store.captureSnapshot(),
restore: (s) => store.restoreSnapshot(s)
};
async function loadRecent() {
@@ -152,7 +117,7 @@
// Restore query from URL so history.back() from preview/recipe
// brings the user back to the same search results.
const urlQ = ($page.url.searchParams.get('q') ?? '').trim();
if (urlQ) query = urlQ;
if (urlQ) store.query = urlQ;
void loadRecent();
void searchFilterStore.load();
const saved = localStorage.getItem('kochwas.allSort');
@@ -188,14 +153,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
searchFilterStore.active;
const q = query.trim();
if (!q || q.length <= 3) return;
if (debounceTimer) clearTimeout(debounceTimer);
searching = true;
webHits = [];
webSearching = false;
webError = null;
debounceTimer = setTimeout(() => void runSearch(q), 150);
store.reSearch();
});
// Sync current query back into the URL as ?q=... via replaceState,
@@ -203,7 +161,7 @@
// when the user clicks a result or otherwise navigates away.
$effect(() => {
if (typeof window === 'undefined') return;
const q = query.trim();
const q = store.query.trim();
const url = new URL(window.location.href);
const current = url.searchParams.get('q') ?? '';
if (q === current) return;
@@ -221,138 +179,17 @@
void loadFavorites(active.id);
});
function filterParam(): string {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
async function runSearch(q: string) {
localExhausted = false;
webPageno = 0;
webExhausted = false;
try {
const res = await fetch(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}${filterParam()}`
);
const body = await res.json();
if (query.trim() !== q) return;
hits = body.hits;
searchedFor = q;
if (hits.length < LOCAL_PAGE) localExhausted = true;
if (hits.length === 0) {
// Gar keine lokalen Treffer → erste Web-Seite gleich laden,
// damit der User nicht extra auf „+ weitere" klicken muss.
webSearching = true;
try {
const wres = await fetch(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}`
);
if (query.trim() !== q) return;
if (!wres.ok) {
const err = await wres.json().catch(() => ({}));
webError = err.message ?? `HTTP ${wres.status}`;
webExhausted = true;
} else {
const wbody = await wres.json();
webHits = wbody.hits;
webPageno = 1;
if (wbody.hits.length === 0) webExhausted = true;
}
} finally {
if (query.trim() === q) webSearching = false;
}
}
} finally {
if (query.trim() === q) searching = false;
}
}
async function loadMore() {
if (loadingMore) return;
const q = query.trim();
if (!q) return;
loadingMore = true;
try {
if (!localExhausted) {
// Noch mehr lokale Treffer holen.
const res = await fetch(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}&offset=${hits.length}${filterParam()}`
);
const body = await res.json();
if (query.trim() !== q) return;
const more = body.hits as SearchHit[];
const seen = new Set(hits.map((h) => h.id));
const deduped = more.filter((h) => !seen.has(h.id));
hits = [...hits, ...deduped];
if (more.length < LOCAL_PAGE) localExhausted = true;
} else if (!webExhausted) {
// Lokale erschöpft → auf Web umschalten / weiterblättern.
const nextPage = webPageno + 1;
webSearching = webHits.length === 0;
try {
const wres = await fetch(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}`
);
if (query.trim() !== q) return;
if (!wres.ok) {
const err = await wres.json().catch(() => ({}));
webError = err.message ?? `HTTP ${wres.status}`;
webExhausted = true;
return;
}
const wbody = await wres.json();
const more = wbody.hits as WebHit[];
const seen = new Set(webHits.map((h) => h.url));
const deduped = more.filter((h) => !seen.has(h.url));
if (deduped.length === 0) {
webExhausted = true;
} else {
webHits = [...webHits, ...deduped];
webPageno = nextPage;
}
} finally {
if (query.trim() === q) webSearching = false;
}
}
} finally {
loadingMore = false;
}
}
$effect(() => {
const q = query.trim();
if (debounceTimer) clearTimeout(debounceTimer);
if (skipNextSearch) {
// Snapshot-Restore hat hits/webHits/searchedFor wiederhergestellt —
// nicht erneut fetchen.
skipNextSearch = false;
return;
}
if (q.length <= 3) {
hits = [];
webHits = [];
searchedFor = null;
searching = false;
webSearching = false;
webError = null;
return;
}
searching = true;
webHits = [];
webSearching = false;
webError = null;
debounceTimer = setTimeout(() => {
void runSearch(q);
}, 300);
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
store.query; // register reactive dep
store.runDebounced();
});
function submit(e: SubmitEvent) {
e.preventDefault();
const q = query.trim();
const q = store.query.trim();
if (q.length <= 3) return;
if (debounceTimer) clearTimeout(debounceTimer);
searching = true;
void runSearch(q);
void store.runSearch(q);
}
async function dismissFromRecent(recipeId: number, e: MouseEvent) {
@@ -367,7 +204,7 @@
});
}
const activeSearch = $derived(query.trim().length > 3);
const activeSearch = $derived(store.query.trim().length > 3);
</script>
<section class="hero">
@@ -378,7 +215,7 @@
<SearchFilter inline />
<input
type="search"
bind:value={query}
bind:value={store.query}
placeholder="Rezept suchen…"
autocomplete="off"
inputmode="search"
@@ -390,12 +227,12 @@
{#if activeSearch}
<section class="results">
{#if searching && hits.length === 0 && webHits.length === 0}
{#if store.searching && store.hits.length === 0 && store.webHits.length === 0}
<SearchLoader scope="local" />
{:else}
{#if hits.length > 0}
{#if store.hits.length > 0}
<ul class="cards">
{#each hits as r (r.id)}
{#each store.hits as r (r.id)}
<li>
<a href={`/recipes/${r.id}`} class="card">
{#if r.image_path}
@@ -413,20 +250,20 @@
</li>
{/each}
</ul>
{:else if searchedFor === query.trim() && !webSearching && webHits.length === 0 && !webError}
<p class="muted no-local-msg">Keine lokalen Rezepte für „{searchedFor}".</p>
{:else if store.searchedFor === store.query.trim() && !store.webSearching && store.webHits.length === 0 && !store.webError}
<p class="muted no-local-msg">Keine lokalen Rezepte für „{store.searchedFor}".</p>
{/if}
{#if webHits.length > 0}
{#if hits.length > 0}
{#if store.webHits.length > 0}
{#if store.hits.length > 0}
<h3 class="sep">Aus dem Internet</h3>
{:else if searchedFor === query.trim()}
{:else if store.searchedFor === store.query.trim()}
<p class="muted no-local-msg">
Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:
Keine lokalen Rezepte für „{store.searchedFor}" — Ergebnisse aus dem Internet:
</p>
{/if}
<ul class="cards">
{#each webHits as w (w.url)}
{#each store.webHits as w (w.url)}
<li>
<a class="card" href={`/preview?url=${encodeURIComponent(w.url)}`}>
{#if w.thumbnail}
@@ -444,16 +281,16 @@
</ul>
{/if}
{#if webSearching}
{#if store.webSearching}
<SearchLoader scope="web" />
{:else if webError && webHits.length === 0}
<p class="error">Internet-Suche zurzeit nicht möglich: {webError}</p>
{:else if store.webError && store.webHits.length === 0}
<p class="error">Internet-Suche zurzeit nicht möglich: {store.webError}</p>
{/if}
{#if searchedFor === query.trim() && !(localExhausted && webExhausted) && !(searching && hits.length === 0)}
{#if store.searchedFor === store.query.trim() && !(store.localExhausted && store.webExhausted) && !(store.searching && store.hits.length === 0)}
<div class="more-cta">
<button class="more-btn" onclick={loadMore} disabled={loadingMore || webSearching}>
{loadingMore || webSearching ? 'Lade …' : '+ weitere Ergebnisse'}
<button class="more-btn" onclick={() => store.loadMore()} disabled={store.loadingMore || store.webSearching}>
{store.loadingMore || store.webSearching ? 'Lade …' : '+ weitere Ergebnisse'}
</button>
</div>
{/if}

View File

@@ -0,0 +1,61 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { z } from 'zod';
import { getDb } from '$lib/server/db';
import { validateBody } from '$lib/server/api-helpers';
import { insertRecipe } from '$lib/server/recipes/repository';
const IngredientSchema = z.object({
position: z.number().int().nonnegative(),
quantity: z.number().nullable(),
unit: z.string().max(30).nullable(),
name: z.string().min(1).max(200),
note: z.string().max(300).nullable(),
raw_text: z.string().max(500),
section_heading: z.string().max(200).nullable()
});
const StepSchema = z.object({
position: z.number().int().positive(),
text: z.string().min(1).max(4000)
});
const CreateRecipeSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).nullable(),
servings_default: z.number().int().nonnegative().nullable(),
servings_unit: z.string().max(30).nullable(),
prep_time_min: z.number().int().nonnegative().nullable(),
cook_time_min: z.number().int().nonnegative().nullable(),
total_time_min: z.number().int().nonnegative().nullable(),
ingredients: z.array(IngredientSchema),
steps: z.array(StepSchema)
});
// Anlegen eines kompletten Rezepts aus Scratch. Wird vom Foto-Import-Flow
// genutzt, nachdem der Nutzer im Editor die AI-Extraktion geprüft/korrigiert
// und auf Speichern getippt hat. Der bestehende /api/recipes/blank-Endpoint
// bleibt für den „leer anlegen"-Flow unverändert.
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const p = validateBody(body, CreateRecipeSchema);
const id = insertRecipe(getDb(), {
id: null,
title: p.title,
description: p.description,
source_url: null,
source_domain: null,
image_path: null,
servings_default: p.servings_default,
servings_unit: p.servings_unit,
prep_time_min: p.prep_time_min,
cook_time_min: p.cook_time_min,
total_time_min: p.total_time_min,
cuisine: null,
category: null,
ingredients: p.ingredients,
steps: p.steps,
tags: []
});
return json({ id }, { status: 201 });
};

View File

@@ -24,7 +24,8 @@ const IngredientSchema = z.object({
unit: z.string().max(30).nullable(),
name: z.string().min(1).max(200),
note: z.string().max(300).nullable(),
raw_text: z.string().max(500)
raw_text: z.string().max(500),
section_heading: z.string().max(200).nullable()
});
const StepSchema = z.object({

View File

@@ -0,0 +1,181 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { extractRecipeFromImage, GeminiError } from '$lib/server/ai/gemini-client';
import { preprocessImage } from '$lib/server/ai/image-preprocess';
import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
import { createRateLimiter } from '$lib/server/ai/rate-limit';
import type { Ingredient, Step } from '$lib/types';
// 20 MB deckt auch Tablet- und iPad-Pro-Fotos ab (oft 10-15 MB JPEG/HEIC).
// Muss zusammen mit BODY_SIZE_LIMIT (docker-compose.prod.yml) hochgezogen werden --
// SvelteKit rejected groessere Bodies frueher und wirft dann undurchsichtige
// "Multipart erwartet"-Fehler.
const MAX_BYTES = 20 * 1024 * 1024;
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
'image/heif'
]);
// Singleton-Limiter: 10 Requests/Minute pro IP. Verhindert Kosten-Runaways
// bei versehentlichem Dauer-Tappen.
const limiter = createRateLimiter({ windowMs: 60_000, max: 10 });
function errJson(status: number, code: string, message: string) {
return json({ code, message }, { status });
}
function buildRawText(q: number | null, u: string | null, name: string): string {
const parts: string[] = [];
if (q !== null) parts.push(String(q).replace('.', ','));
if (u) parts.push(u);
parts.push(name);
return parts.join(' ');
}
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
const ip = getClientAddress();
if (!limiter.check(ip)) {
return errJson(
429,
'RATE_LIMITED',
'Zu viele Anfragen — bitte einen Moment warten.'
);
}
// Header-Snapshot fuer Diagnose beim Upload-Parse-Fehler. Wir loggen
// Content-Type, -Length und User-Agent — nichts, was Inhalt verraet.
const contentType = request.headers.get('content-type') ?? '(missing)';
const contentLength = request.headers.get('content-length') ?? '(missing)';
const userAgent = request.headers.get('user-agent')?.slice(0, 120) ?? '(missing)';
let form: FormData;
try {
form = await request.formData();
} catch (e) {
const err = e as Error;
console.warn(
`[extract-from-photo] formData() failed: name=${err.name} msg=${err.message} ` +
`ct="${contentType}" len=${contentLength} ua="${userAgent}"`
);
return errJson(
400,
'BAD_REQUEST',
`Upload konnte nicht gelesen werden (${err.name}: ${err.message}).`
);
}
const photo = form.get('photo');
if (!(photo instanceof Blob)) {
console.warn(
`[extract-from-photo] photo field missing or not a Blob. ct="${contentType}" ` +
`len=${contentLength} fields=${[...form.keys()].join(',')}`
);
return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.');
}
console.info(
`[extract-from-photo] received photo size=${photo.size} mime="${photo.type}" ua="${userAgent}"`
);
if (photo.size > MAX_BYTES) {
return errJson(
413,
'PAYLOAD_TOO_LARGE',
`Foto zu groß (max ${MAX_BYTES / 1024 / 1024} MB).`
);
}
if (!ALLOWED_MIME.has(photo.type)) {
return errJson(
415,
'UNSUPPORTED_MEDIA_TYPE',
`MIME "${photo.type}" nicht unterstützt.`
);
}
const rawBuffer = Buffer.from(await photo.arrayBuffer());
let preprocessed: { buffer: Buffer; mimeType: 'image/jpeg' };
try {
preprocessed = await preprocessImage(rawBuffer);
} catch (e) {
return errJson(
415,
'UNSUPPORTED_MEDIA_TYPE',
`Bild konnte nicht gelesen werden: ${(e as Error).message}`
);
}
const startedAt = Date.now();
let extracted;
try {
extracted = await extractRecipeFromImage(
preprocessed.buffer,
preprocessed.mimeType
);
} catch (e) {
if (e instanceof GeminiError) {
const status =
e.code === 'AI_RATE_LIMITED'
? 429
: e.code === 'AI_TIMEOUT'
? 503
: e.code === 'AI_NOT_CONFIGURED'
? 503
: 503;
// Nur Code + Meta loggen, niemals Prompt/Response-Inhalt.
console.warn(
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes`
);
return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.');
}
console.warn(`[extract-from-photo] UNEXPECTED ${(e as Error).message}`);
return errJson(503, 'AI_FAILED', 'Die Bild-Analyse ist fehlgeschlagen.');
}
// Minimum-Gültigkeit: Titel + (mind. 1 Zutat ODER mind. 1 Schritt).
if (
!extracted.title.trim() ||
(extracted.ingredients.length === 0 && extracted.steps.length === 0)
) {
return errJson(
422,
'NO_RECIPE_IN_IMAGE',
'Ich konnte kein Rezept im Bild erkennen.'
);
}
const ingredients: Ingredient[] = extracted.ingredients.map((i, idx) => ({
position: idx + 1,
quantity: i.quantity,
unit: i.unit,
name: i.name,
note: i.note,
raw_text: buildRawText(i.quantity, i.unit, i.name),
section_heading: null
}));
const steps: Step[] = extracted.steps.map((s, idx) => ({
position: idx + 1,
text: s.text
}));
return json({
recipe: {
id: null,
title: extracted.title,
description: pickRandomPhrase(),
source_url: null,
source_domain: null,
image_path: null,
servings_default: extracted.servings_default,
servings_unit: extracted.servings_unit,
prep_time_min: extracted.prep_time_min,
cook_time_min: extracted.cook_time_min,
total_time_min: extracted.total_time_min,
cuisine: null,
category: null,
ingredients,
steps,
tags: []
}
});
};

View File

@@ -0,0 +1,12 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const load: PageServerLoad = async () => {
if (!env.GEMINI_API_KEY) {
error(503, {
message: 'Foto-Import ist nicht konfiguriert (GEMINI_API_KEY fehlt).'
});
}
return {};
};

View File

@@ -0,0 +1,271 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
Camera,
ImageUp,
Loader2,
Wand2,
AlertTriangle,
RotateCw,
FilePlus,
X
} from 'lucide-svelte';
import RecipeEditor from '$lib/components/RecipeEditor.svelte';
import { PhotoUploadStore } from '$lib/client/photo-upload.svelte';
import { alertAction } from '$lib/client/confirm.svelte';
import { network } from '$lib/client/network.svelte';
import type { Recipe, Ingredient, Step } from '$lib/types';
const store = new PhotoUploadStore();
let saving = $state(false);
let cameraInput = $state<HTMLInputElement | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
function onPick(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) void store.upload(file);
}
type SavePatch = {
title: string;
description: string | null;
servings_default: number | null;
prep_time_min: number | null;
cook_time_min: number | null;
total_time_min: number | null;
ingredients: Ingredient[];
steps: Step[];
};
async function onSave(patch: SavePatch) {
if (!store.recipe) return;
saving = true;
try {
const body = {
title: patch.title,
description: patch.description,
servings_default: patch.servings_default,
servings_unit: store.recipe.servings_unit,
prep_time_min: patch.prep_time_min,
cook_time_min: patch.cook_time_min,
total_time_min: patch.total_time_min,
ingredients: patch.ingredients,
steps: patch.steps
};
const res = await fetch('/api/recipes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
await alertAction({
title: 'Speichern fehlgeschlagen',
message: err.message ?? `HTTP ${res.status}`
});
return;
}
const { id } = await res.json();
await goto(`/recipes/${id}`);
} finally {
saving = false;
}
}
function onCancel() {
history.back();
}
</script>
<svelte:head><title>Rezept aus Foto — Kochwas</title></svelte:head>
{#if store.status === 'idle'}
<section class="picker">
<Camera size={48} strokeWidth={1.5} />
<h1>Rezept aus Foto</h1>
<p class="hint">
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
scharf, gut ausgeleuchtet.
</p>
<div class="row">
<button
type="button"
class="btn primary"
onclick={() => cameraInput?.click()}
disabled={!network.online}
>
<Camera size={18} strokeWidth={2} />
<span>Kamera</span>
</button>
<button
type="button"
class="btn ghost"
onclick={() => fileInput?.click()}
disabled={!network.online}
>
<ImageUp size={18} strokeWidth={2} />
<span>Aus Dateien</span>
</button>
</div>
<!-- Zwei separate Inputs: capture="environment" oeffnet direkt die Kamera,
das andere zeigt den Datei-/Fotomediathek-Picker. Android-Chrome auf
Tablet zeigt sonst bei capture="environment" nur die Kamera; ohne
capture dagegen nur den Datei-Picker. Explizite Wahl ist eindeutig. -->
<input
bind:this={cameraInput}
type="file"
accept="image/*"
capture="environment"
hidden
onchange={onPick}
/>
<input
bind:this={fileInput}
type="file"
accept="image/*"
hidden
onchange={onPick}
/>
{#if !network.online}
<p class="offline">Offline — diese Funktion braucht Internet.</p>
{/if}
</section>
{:else if store.status === 'loading'}
<section class="state" aria-live="polite">
<div class="spin"><Loader2 size={48} /></div>
<p>Lese das Rezept…</p>
<button type="button" class="btn ghost" onclick={() => store.abort()}>
<X size={18} /><span>Abbrechen</span>
</button>
</section>
{:else if store.status === 'error'}
{#if store.errorCode === 'NO_RECIPE_IN_IMAGE'}
<section class="state yellow" role="alert">
<AlertTriangle size={40} />
<h2>Kein Rezept im Bild</h2>
<p>Ich konnte auf dem Foto kein Rezept erkennen.</p>
<div class="row">
<button
type="button"
class="btn primary"
onclick={() => {
store.reset();
fileInput?.click();
}}
>
<Camera size={18} /><span>Anderes Foto</span>
</button>
<a class="btn ghost" href="/">
<FilePlus size={18} /><span>Startseite</span>
</a>
</div>
</section>
{:else}
<section class="state red" role="alert">
<AlertTriangle size={40} />
<h2>Fehler</h2>
<p>{store.errorMessage ?? 'Unbekannter Fehler.'}</p>
<div class="row">
<button type="button" class="btn primary" onclick={() => store.retry()}>
<RotateCw size={18} /><span>Nochmal versuchen</span>
</button>
<button type="button" class="btn ghost" onclick={() => store.reset()}>
<Camera size={18} /><span>Anderes Foto</span>
</button>
</div>
</section>
{/if}
{:else if store.status === 'success' && store.recipe}
<div class="banner">
<Wand2 size={18} />
<span>Aus Foto erstellt — bitte prüfen und ggf. korrigieren.</span>
</div>
<RecipeEditor
recipe={store.recipe as Recipe}
{saving}
onsave={onSave}
oncancel={onCancel}
/>
{/if}
<style>
.picker,
.state {
text-align: center;
padding: 3rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.hint {
color: #666;
max-width: 400px;
line-height: 1.45;
}
.btn {
padding: 0.8rem 1.1rem;
min-height: 48px;
border-radius: 10px;
cursor: pointer;
font-size: 1rem;
border: 0;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn.primary {
background: #2b6a3d;
color: white;
}
.btn.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn.ghost {
background: white;
color: #444;
border: 1px solid #cfd9d1;
}
.row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.state.yellow {
background: #fff6d7;
border: 1px solid #e6d48a;
border-radius: 12px;
}
.state.red {
background: #fde4e4;
border: 1px solid #e6a0a0;
border-radius: 12px;
}
.banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1rem;
background: #eef8ef;
border: 1px solid #b7d9c0;
border-radius: 10px;
margin: 0.75rem 0 1rem;
color: #2b6a3d;
}
.spin {
animation: spin 1s linear infinite;
display: inline-flex;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.offline {
color: #a05b00;
font-size: 0.9rem;
}
</style>

View File

@@ -56,11 +56,13 @@ self.addEventListener('fetch', (event) => {
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));
} else if (strategy === 'network-first') {
event.respondWith(networkFirstWithTimeout(req, DATA_CACHE, NETWORK_TIMEOUT_MS));
}
});
const NETWORK_TIMEOUT_MS = 3000;
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
@@ -70,16 +72,36 @@ async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
return fresh;
}
async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
// Network-first mit Timeout-Fallback: frische Daten gewinnen, wenn das Netz
// innerhalb von NETWORK_TIMEOUT_MS antwortet. Sonst wird der Cache geliefert
// (falls vorhanden), während der Netz-Fetch noch im Hintergrund weiterläuft
// und den Cache für den nächsten Request aktualisiert. Ohne Cache wartet der
// Client trotzdem aufs Netz, weil ein Error-Response hier nichts nützt.
async function networkFirstWithTimeout(
req: Request,
cacheName: string,
timeoutMs: number
): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(req);
const fetchPromise = fetch(req)
const networkPromise: Promise<Response | null> = fetch(req)
.then((res) => {
if (res.ok) cache.put(req, res.clone()).catch(() => {});
return res;
})
.catch(() => hit ?? Response.error());
return hit ?? fetchPromise;
.catch(() => null);
const timeoutPromise = new Promise<'timeout'>((resolve) =>
setTimeout(() => resolve('timeout'), timeoutMs)
);
const winner = await Promise.race([networkPromise, timeoutPromise]);
if (winner instanceof Response) return winner;
// Timeout oder Netzwerk-Fehler: Cache bevorzugen, sonst auf Netz warten.
const hit = await cache.match(req);
if (hit) return hit;
const late = await networkPromise;
return late ?? Response.error();
}
const META_CACHE = 'kochwas-meta';

View File

@@ -0,0 +1,68 @@
# E2E-Tests gegen kochwas-dev
Playwright-Smoketests gegen ein deployed Environment — standardmaessig
`https://kochwas-dev.siegeln.net`. Loest die bisherigen manuellen
MCP-Runs ab.
## Setup (einmalig)
```bash
npm install
npx playwright install chromium
```
## Ausfuehren
```bash
npm run test:e2e:remote # Headless, alle Tests
npm run test:e2e:remote -- --ui # Mit Playwright-UI (Trace-Viewer)
npm run test:e2e:remote -- --debug # Step-by-Step
```
Alternative URL:
```bash
E2E_REMOTE_URL=https://kochwas.siegeln.net npm run test:e2e:remote
```
## Was abgedeckt ist
### Happy Paths (UI)
| Spec | Was |
|---|---|
| `homepage.spec.ts` | H1, Recents/Alle-Rezepte-Sektionen, Sort-Tabs rendern unterschiedlich, keine Console-Errors |
| `search.spec.ts` | Lokaler Treffer, Web-Fallback, Empty-State, Deep-Link `?q=` |
| `profile.spec.ts` | Switcher-Dialog, Auswahl persistiert, "Deine Favoriten" erscheint nach Login |
| `recipe-detail.spec.ts` | Header, Portionen-Skalierung (4->6, Mengen proportional), Favorit-Toggle, Rating persistiert ueber Reload, Gekocht-Counter, Wunschliste-Toggle |
| `comments.spec.ts` | Eigenen Kommentar erstellen + via UI-Button loeschen; fremder Kommentar hat keinen Delete-Button |
| `wishlist.spec.ts` | Seite laedt, Sort-Tabs, Header-Badge spiegelt API-Zaehler |
| `preview.spec.ts` | Guard ohne `?url=`, echte URL laedt JSON-LD-Parsing, unparsbare URL zeigt error-box |
| `admin.spec.ts` | Alle 4 Admin-Subrouten laden mit Tab-Nav, `/admin` redirected |
### Negative Paths (API)
| Spec | Was |
|---|---|
| `api-errors.spec.ts` | `parsePositiveIntParam` → 400 `Invalid id` (4 Call-Sites), `validateBody` → 400 `{message, issues}` (4 Call-Sites), 404 auf missing Ressource, Positiv-Sanity fuer /health, /profiles, /domains |
## Design-Entscheidungen
**`workers: 1`.** Tests mutieren echte Daten auf `kochwas-dev` (Rating,
Favorit, Wunschliste, Kommentare). Parallelitaet wuerde Race-Conditions
geben. `afterEach` raeumt per API auf — idempotent.
**Hardcoded Test-Fixtures.** Rezept-ID 66 (Chicken Teriyaki) und
Profile 1/2/3 (Hendrik/Verena/Leana) sind stabil auf dev. Bei
DB-Reset muessen ggf. die Konstanten angepasst werden.
**Kein Build/Server-Start.** Im Gegensatz zur lokalen `playwright.config.ts`
startet diese Config keinen Preview-Server — die Tests laufen gegen das
CI-Build auf dev.
## Was NICHT hier ist
- **Service-Worker-Lifecycle / Offline** → `tests/e2e/offline.spec.ts` (lokal).
- **Bild-Upload** — File-Dialog + echte Dateien; nur manuell sinnvoll.
- **Drucken** — oeffnet `window.print()`, headless unzuverlaessig.
- **Sync unter Last** — braucht dediziertes Harness, nicht Smoke-Scope.

View File

@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test.describe('Admin-Routen', () => {
const SUBROUTES = ['domains', 'profiles', 'backup', 'app'] as const;
for (const sub of SUBROUTES) {
test(`/admin/${sub} laedt mit Nav-Tabs`, async ({ page }) => {
await page.goto(`/admin/${sub}`);
// Alle Admin-Subseiten haben dieselbe Tab-Leiste.
for (const label of ['Domains', 'Profile', 'Backup', 'App']) {
await expect(page.getByRole('link', { name: label })).toBeVisible();
}
});
}
test('/admin redirected auf /admin/domains', async ({ page }) => {
await page.goto('/admin');
await expect(page).toHaveURL(/\/admin\/domains$/);
});
});

View File

@@ -0,0 +1,101 @@
import { test, expect } from '@playwright/test';
// Negative-Path Tests fuer die api-helpers: parsePositiveIntParam und
// validateBody. Jeder neue API-Handler sollte dieselben Error-Shapes
// liefern — wenn dieser Suite-Block kippt, ist der Helper-Contract kaputt.
test.describe('API Error-Shapes', () => {
test.describe('parsePositiveIntParam', () => {
test('GET /api/recipes/abc -> 400 Invalid id', async ({ request }) => {
const r = await request.get('/api/recipes/abc');
expect(r.status()).toBe(400);
expect(await r.json()).toEqual({ message: 'Invalid id' });
});
test('GET /api/recipes/-1 -> 400 Invalid id', async ({ request }) => {
const r = await request.get('/api/recipes/-1');
expect(r.status()).toBe(400);
expect(await r.json()).toEqual({ message: 'Invalid id' });
});
test('GET /api/recipes/0 -> 400 Invalid id', async ({ request }) => {
const r = await request.get('/api/recipes/0');
expect(r.status()).toBe(400);
expect(await r.json()).toEqual({ message: 'Invalid id' });
});
test('POST /api/recipes/abc/comments -> 400 Invalid id', async ({ request }) => {
const r = await request.post('/api/recipes/abc/comments', { data: {} });
expect(r.status()).toBe(400);
expect(await r.json()).toEqual({ message: 'Invalid id' });
});
});
test.describe('validateBody', () => {
test('POST /api/wishlist leer -> 400 {message, issues}', async ({ request }) => {
const r = await request.post('/api/wishlist', { data: {} });
expect(r.status()).toBe(400);
const body = (await r.json()) as { message: string; issues?: unknown[] };
expect(body.message).toBe('Invalid body');
expect(Array.isArray(body.issues)).toBe(true);
expect((body.issues ?? []).length).toBeGreaterThanOrEqual(2); // recipe_id + profile_id
});
test('POST /api/recipes/66/comments leer -> 400 {message, issues}', async ({ request }) => {
const r = await request.post('/api/recipes/66/comments', { data: {} });
expect(r.status()).toBe(400);
const body = (await r.json()) as { message: string; issues?: unknown[] };
expect(body.message).toBe('Invalid body');
expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1); // profile_id oder text
});
test('PUT /api/recipes/66/favorite leer -> 400 {message, issues}', async ({ request }) => {
const r = await request.put('/api/recipes/66/favorite', { data: {} });
expect(r.status()).toBe(400);
const body = (await r.json()) as { message: string; issues?: unknown[] };
expect(body.message).toBe('Invalid body');
expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1);
});
test('POST /api/domains leer -> 400 {message, issues}', async ({ request }) => {
const r = await request.post('/api/domains', { data: {} });
expect(r.status()).toBe(400);
const body = (await r.json()) as { message: string; issues?: unknown[] };
expect(body.message).toBe('Invalid body');
expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1);
});
});
test.describe('404 auf missing Ressourcen', () => {
test('GET /api/recipes/99999 -> 404 Recipe not found', async ({ request }) => {
const r = await request.get('/api/recipes/99999');
expect(r.status()).toBe(404);
expect(await r.json()).toEqual({ message: 'Recipe not found' });
});
});
test.describe('Positive Sanity-Checks', () => {
test('GET /api/health -> 200 mit db:"ok"', async ({ request }) => {
const r = await request.get('/api/health');
expect(r.status()).toBe(200);
const body = (await r.json()) as { db: string };
expect(body.db).toBe('ok');
});
test('GET /api/profiles -> drei Profile', async ({ request }) => {
const r = await request.get('/api/profiles');
expect(r.status()).toBe(200);
const body = (await r.json()) as { id: number; name: string }[];
expect(body.length).toBeGreaterThanOrEqual(3);
const names = body.map((p) => p.name).sort();
expect(names).toEqual(expect.arrayContaining(['Hendrik', 'Leana', 'Verena']));
});
test('GET /api/domains -> liefert Array', async ({ request }) => {
const r = await request.get('/api/domains');
expect(r.status()).toBe(200);
const body = await r.json();
expect(Array.isArray(body)).toBe(true);
});
});
});

View File

@@ -0,0 +1,71 @@
import { test, expect } from '@playwright/test';
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
import { cleanupE2EComments, deleteComment } from './fixtures/api-cleanup';
const RECIPE_ID = 66;
test.describe('Kommentare', () => {
test.beforeEach(async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
// Stray E2E-Kommentare aus abgebrochenen Runs wegraeumen.
await cleanupE2EComments(request, RECIPE_ID, HENDRIK_ID);
});
test.afterEach(async ({ request }) => {
await cleanupE2EComments(request, RECIPE_ID, HENDRIK_ID);
});
test('Kommentar erstellen, Delete-Button erscheint, Loeschen via UI', async ({
page
}) => {
const unique = `E2E ${Date.now()}`;
await page.goto(`/recipes/${RECIPE_ID}`);
await page.getByRole('textbox').filter({ hasText: '' }).last().fill(unique);
await page.getByRole('button', { name: 'Kommentar speichern' }).click();
// Neuer Kommentar sichtbar
await expect(page.getByText(unique)).toBeVisible({ timeout: 5000 });
// Delete-Button NUR beim eigenen Kommentar
const delBtn = page.getByRole('button', { name: 'Kommentar löschen' });
await expect(delBtn).toBeVisible();
await delBtn.click();
// ConfirmDialog "Kommentar loeschen?" mit Loeschen-Button.
// Es gibt mehrere "Löschen"-Buttons auf der Seite (Rezept-Delete,
// Kommentar-Trash, Dialog-Bestaetigung) — deshalb Locator auf den
// Dialog einschraenken.
const dialog = page.getByRole('dialog', { name: /Kommentar löschen/i });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Löschen' }).click();
await expect(page.getByText(unique)).not.toBeVisible({ timeout: 5000 });
});
test('Fremder Kommentar zeigt KEINEN Delete-Button fuers aktuelle Profil', async ({
page,
request
}) => {
// Wir legen den Kommentar fuer ein anderes Profil (Leana, id=3) per API an.
const text = `E2E fremd ${Date.now()}`;
const res = await request.post(`/api/recipes/${RECIPE_ID}/comments`, {
data: { profile_id: 3, text }
});
expect(res.status()).toBe(201);
const { id } = (await res.json()) as { id: number };
try {
await page.goto(`/recipes/${RECIPE_ID}`);
const item = page
.locator('.comments li')
.filter({ hasText: text });
await expect(item).toBeVisible();
await expect(
item.getByRole('button', { name: 'Kommentar löschen' })
).toHaveCount(0);
} finally {
await deleteComment(request, RECIPE_ID, id);
}
});
});

View File

@@ -0,0 +1,67 @@
import type { APIRequestContext } from '@playwright/test';
// Cleanup-Helfer fuer afterEach-Hooks. Alle sind idempotent — wenn der
// Zustand schon weg ist (z. B. der Test ist zwischen Action und Check
// abgebrochen), fliegt nichts.
export async function clearRating(
api: APIRequestContext,
recipeId: number,
profileId: number
): Promise<void> {
await api.delete(`/api/recipes/${recipeId}/rating`, {
data: { profile_id: profileId }
});
}
export async function clearFavorite(
api: APIRequestContext,
recipeId: number,
profileId: number
): Promise<void> {
await api.delete(`/api/recipes/${recipeId}/favorite`, {
data: { profile_id: profileId }
});
}
export async function removeFromWishlist(
api: APIRequestContext,
recipeId: number,
profileId: number
): Promise<void> {
await api.delete(`/api/wishlist/${recipeId}?profile_id=${profileId}`);
}
export async function deleteComment(
api: APIRequestContext,
recipeId: number,
commentId: number
): Promise<void> {
await api.delete(`/api/recipes/${recipeId}/comments`, {
data: { comment_id: commentId }
});
}
/**
* Safety-Net: loescht alle E2E-Kommentare eines Profils. Gedacht fuer
* afterEach/afterAll, falls ein Test abbricht bevor der eigene Cleanup
* greift. Markiert E2E-Kommentare am Prefix "E2E ".
*/
export async function cleanupE2EComments(
api: APIRequestContext,
recipeId: number,
profileId: number
): Promise<void> {
const res = await api.get(`/api/recipes/${recipeId}/comments`);
if (!res.ok()) return;
const list = (await res.json()) as {
id: number;
profile_id: number;
text: string;
}[];
for (const c of list) {
if (c.profile_id === profileId && c.text.startsWith('E2E ')) {
await deleteComment(api, recipeId, c.id);
}
}
}

View File

@@ -0,0 +1,26 @@
import type { Page } from '@playwright/test';
// Profil-IDs auf kochwas-dev: 1 = Hendrik, 2 = Verena, 3 = Leana.
// Die Tests hardcoden Hendrik als Standard, weil die Dev-DB diese
// Profile stabil enthaelt.
export const HENDRIK_ID = 1;
export const VERENA_ID = 2;
export const LEANA_ID = 3;
/**
* Setzt das aktive Profil in localStorage, BEVOR die Seite geladen wird.
* addInitScript laeuft vor jedem Skript der Seite — damit ist das Profil
* schon da, wenn profileStore.load() das erste Mal liest.
*/
export async function setActiveProfile(page: Page, id: number): Promise<void> {
await page.addInitScript(
(pid) => window.localStorage.setItem('kochwas.activeProfileId', String(pid)),
id
);
}
export async function clearActiveProfile(page: Page): Promise<void> {
await page.addInitScript(() =>
window.localStorage.removeItem('kochwas.activeProfileId')
);
}

View File

@@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test';
test.describe('Startseite', () => {
test('laedt mit H1, Zuletzt-hinzugefuegt und Alle-Rezepte', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Kochwas/);
await expect(page.getByRole('heading', { level: 1, name: 'Kochwas' })).toBeVisible();
await expect(
page.getByRole('heading', { level: 2, name: 'Zuletzt hinzugefügt' })
).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: 'Alle Rezepte' })).toBeVisible();
});
test('Sort-Tabs rendern unterschiedliche Top-Eintraege', async ({ page }) => {
await page.goto('/');
// Liste unter "Alle Rezepte"
const allSection = page.locator('section', { has: page.getByRole('heading', { name: 'Alle Rezepte' }) });
const firstItem = () => allSection.locator('li a').first().innerText();
await page.getByRole('tab', { name: 'Name' }).click();
await page.waitForTimeout(400);
const nameTop = await firstItem();
await page.getByRole('tab', { name: 'Hinzugefügt' }).click();
await page.waitForTimeout(400);
const addedTop = await firstItem();
expect(nameTop).not.toEqual(addedTop);
});
test('hat keine Console-Errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// 404s auf externen Bildern (chefkoch-cdn, cloudfront) ignorieren —
// das ist kein App-Fehler, sondern externe Thumbnails.
const appErrors = errors.filter((e) => !/Failed to load resource/i.test(e));
expect(appErrors).toEqual([]);
});
});

View File

@@ -0,0 +1,216 @@
import { test, expect, type APIRequestContext } from '@playwright/test';
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
// Helper: idempotent recipe delete.
async function deleteRecipe(request: APIRequestContext, id: number): Promise<void> {
await request.delete(`/api/recipes/${id}`);
}
// Shared ingredient payload builder — fills all required Zod fields.
function makeIngredient(
position: number,
name: string,
section_heading: string | null,
overrides: Partial<{
quantity: number | null;
unit: string | null;
note: string | null;
raw_text: string;
}> = {}
) {
return {
position,
quantity: overrides.quantity ?? null,
unit: overrides.unit ?? null,
name,
note: overrides.note ?? null,
raw_text: overrides.raw_text ?? name,
section_heading
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-test cleanup scaffolding — single variable, reset in beforeEach.
// ─────────────────────────────────────────────────────────────────────────────
let createdId: number | null = null;
test.beforeEach(() => {
createdId = null;
});
test.afterEach(async ({ request }) => {
if (createdId !== null) {
await deleteRecipe(request, createdId);
createdId = null;
}
});
// ─────────────────────────────────────────────────────────────────────────────
// Test 1 — pure API roundtrip (no browser needed)
// ─────────────────────────────────────────────────────────────────────────────
test('API: section_heading persistiert ueber PATCH + GET', async ({ request }) => {
// 1. Create blank recipe.
const createRes = await request.post('/api/recipes/blank');
expect(createRes.status()).toBe(200);
const { id } = (await createRes.json()) as { id: number };
createdId = id;
// 2. PATCH with 3 ingredients carrying section_heading values.
const patchRes = await request.patch(`/api/recipes/${id}`, {
data: {
ingredients: [
makeIngredient(1, 'Mehl', 'Fuer den Teig', { quantity: 200, unit: 'g', raw_text: '200 g Mehl' }),
makeIngredient(2, 'Zucker', null, { quantity: 100, unit: 'g', raw_text: '100 g Zucker' }),
makeIngredient(3, 'Beeren', 'Fuer die Fuellung', { quantity: 150, unit: 'g', raw_text: '150 g Beeren' })
]
}
});
expect(patchRes.status()).toBe(200);
// 3. GET and assert persisted values.
const getRes = await request.get(`/api/recipes/${id}`);
expect(getRes.status()).toBe(200);
const body = (await getRes.json()) as {
recipe: { ingredients: Array<{ name: string; section_heading: string | null }> };
};
const ings = body.recipe.ingredients;
const mehl = ings.find((i) => i.name === 'Mehl');
const zucker = ings.find((i) => i.name === 'Zucker');
const beeren = ings.find((i) => i.name === 'Beeren');
expect(mehl?.section_heading).toBe('Fuer den Teig');
expect(zucker?.section_heading).toBeNull();
expect(beeren?.section_heading).toBe('Fuer die Fuellung');
});
// ─────────────────────────────────────────────────────────────────────────────
// Test 2 — UI edit flow: add section, save, assert view renders heading
// ─────────────────────────────────────────────────────────────────────────────
test('Editor: Abschnitt via Inline-Button anlegen, View rendert Ueberschrift', async ({
page,
request
}) => {
// 1. Create blank recipe via API.
const createRes = await request.post('/api/recipes/blank');
expect(createRes.status()).toBe(200);
const { id } = (await createRes.json()) as { id: number };
createdId = id;
// 2. Open recipe in edit mode.
await setActiveProfile(page, HENDRIK_ID);
await page.goto(`/recipes/${id}?edit=1`);
// 3. Add two ingredient rows.
const addIngBtn = page.getByRole('button', { name: /Zutat hinzufügen/i });
await addIngBtn.click();
await addIngBtn.click();
// Fill the two ingredient rows by aria-label "Zutat" inputs.
const nameInputs = page.locator('.ing-list .ing-row input[aria-label="Zutat"]');
await nameInputs.nth(0).fill('Mehl');
await nameInputs.nth(1).fill('Zucker');
// 4. Click "Abschnitt hinzufügen" above the first row.
// The button is inside .section-insert which is opacity:0 until hover/focus.
// Hover the ing-list to trigger visibility, then click.
await page.hover('.ing-list');
await page.locator('.ing-list .add-section').first().click();
// 5. Type heading text into the section-heading input that appeared.
const headingInput = page.locator('.ing-list input[aria-label="Sektionsüberschrift"]').first();
await headingInput.fill('Fuer den Teig');
// 6. Save — exact match to avoid colliding with "Kommentar speichern".
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
// After save, editMode becomes false — page switches to view mode.
// Wait for the section-heading element to confirm view mode is active.
await expect(page.locator('.ing-list .section-heading').first()).toBeVisible({ timeout: 8000 });
// 7. Assert heading text is rendered.
await expect(page.locator('.ing-list .section-heading').first()).toHaveText('Fuer den Teig');
});
// ─────────────────────────────────────────────────────────────────────────────
// Test 3 — UI: remove an existing section heading, save, confirm it's gone
// ─────────────────────────────────────────────────────────────────────────────
test('Editor: Sektion entfernen speichert ohne Ueberschrift', async ({ page, request }) => {
// 1. Create blank recipe and pre-populate via API.
const createRes = await request.post('/api/recipes/blank');
expect(createRes.status()).toBe(200);
const { id } = (await createRes.json()) as { id: number };
createdId = id;
await request.patch(`/api/recipes/${id}`, {
data: {
ingredients: [makeIngredient(1, 'Butter', 'Teig', { raw_text: 'Butter' })]
}
});
// 2. Open editor.
await setActiveProfile(page, HENDRIK_ID);
await page.goto(`/recipes/${id}?edit=1`);
// The section-heading-row should be visible since heading = 'Teig'.
const removeBtn = page
.locator('.ing-list')
.getByRole('button', { name: 'Sektion entfernen' });
await expect(removeBtn).toBeVisible({ timeout: 6000 });
// 3. Click the section-remove X button.
await removeBtn.click();
// 4. Save — exact match to avoid colliding with "Kommentar speichern".
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
// Wait for view mode (editMode = false makes RecipeEditor unmount).
// The .section-heading-row is part of the editor; in view mode we check
// the view's .ing-list for absence of .section-heading items.
await expect(page.locator('.ing-list .section-heading')).toHaveCount(0, { timeout: 8000 });
});
// ─────────────────────────────────────────────────────────────────────────────
// Test 4 — empty heading trims to null on save
// ─────────────────────────────────────────────────────────────────────────────
test('Editor: leeres Heading wird beim Speichern zu null', async ({ page, request }) => {
// 1. Create blank recipe.
const createRes = await request.post('/api/recipes/blank');
expect(createRes.status()).toBe(200);
const { id } = (await createRes.json()) as { id: number };
createdId = id;
// 2. Open editor, add one ingredient, open section input and leave it empty.
await setActiveProfile(page, HENDRIK_ID);
await page.goto(`/recipes/${id}?edit=1`);
await page.getByRole('button', { name: /Zutat hinzufügen/i }).click();
await page.locator('.ing-list .ing-row input[aria-label="Zutat"]').first().fill('Eier');
// Trigger add-section visibility and click.
await page.hover('.ing-list');
await page.locator('.ing-list .add-section').first().click();
// Leave the heading input empty (do not type anything).
// The save() function trims '' → null.
// 3. Save — exact match to avoid colliding with "Kommentar speichern".
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
// Wait until view mode is active (editor gone).
await expect(page.locator('.ing-list .section-heading')).toHaveCount(0, { timeout: 8000 });
// 4. Confirm via API that section_heading is null.
const getRes = await request.get(`/api/recipes/${id}`);
expect(getRes.status()).toBe(200);
const body = (await getRes.json()) as {
recipe: { ingredients: Array<{ name: string; section_heading: string | null }> };
};
const eier = body.recipe.ingredients.find((i) => i.name === 'Eier');
expect(eier?.section_heading).toBeNull();
});

View File

@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test';
import { resolve } from 'node:path';
// Wir stubben den Extract-Endpoint server-side nicht (das Feature liegt auf
// dev hinter dem echten Gemini-Key), sondern auf context-Ebene: Playwright
// fängt alle Requests auf /api/recipes/extract-from-photo ab und liefert
// einen deterministischen JSON-Body zurück. Keine Gemini-Kosten in CI.
test('Foto-Import Happy-Path mit gestubtem Extract-Endpoint', async ({
page,
context
}) => {
await context.route('**/api/recipes/extract-from-photo', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
recipe: {
id: null,
title: 'E2E Testrezept',
description: 'Aus dem Bild herbeigezaubert.',
source_url: null,
source_domain: null,
image_path: null,
servings_default: 2,
servings_unit: 'Portionen',
prep_time_min: 5,
cook_time_min: 10,
total_time_min: null,
cuisine: null,
category: null,
ingredients: [
{
position: 1,
quantity: 1,
unit: 'Stk',
name: 'E2E-Apfel',
note: null,
raw_text: '1 Stk E2E-Apfel',
section_heading: null
}
],
steps: [{ position: 1, text: 'Apfel waschen.' }],
tags: []
}
})
});
});
await page.goto('/new/from-photo');
await expect(page.getByRole('heading', { name: 'Rezept aus Foto' })).toBeVisible();
const fixture = resolve(__dirname, '../../fixtures/photo-recipe/sample-printed.jpg');
await page.locator('input[type="file"]').setInputFiles(fixture);
await expect(page.getByText('Aus Foto erstellt')).toBeVisible({ timeout: 5000 });
// Titel-Feld (das erste text-input im Editor)
await expect(page.locator('input[type="text"]').first()).toHaveValue(
'E2E Testrezept'
);
});
test('Camera-Icon im Header wird disabled, wenn der Client offline geht', async ({
page,
context
}) => {
await page.goto('/');
const icon = page.locator('[aria-label="Rezept aus Foto erstellen"]');
// Nur relevant, wenn der Dev-Server einen Gemini-Key hat — andernfalls ist
// das Icon per Graceful-Degradation gar nicht gerendert und der Test wird
// hier early-skipped. (Im Prod und Dev mit Key gilt der zweite Pfad.)
if ((await icon.count()) === 0) {
test.skip(true, 'Dev-Env hat keinen GEMINI_API_KEY gesetzt.');
return;
}
await expect(icon).toBeVisible();
await context.setOffline(true);
await page.waitForFunction(() => !navigator.onLine);
await expect(icon).toHaveClass(/disabled/);
});

View File

@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
test.describe('Preview-Route', () => {
test('ohne ?url= zeigt Guard-Fehlermeldung', async ({ page }) => {
await page.goto('/preview');
await expect(page.getByText(/Kein \?url=-Parameter/)).toBeVisible();
await expect(page.getByRole('heading', { name: /kein Rezept/i })).toBeVisible();
});
test('mit echter URL laedt Vorschau + Speichern-Button', async ({ page }) => {
const u = encodeURIComponent('https://emmikochteinfach.de/chicken-teriyaki/');
await page.goto(`/preview?url=${u}`);
await expect(page.getByText('Vorschau — noch nicht gespeichert')).toBeVisible({
timeout: 20000
});
await expect(page.getByRole('button', { name: /speichern/i })).toBeVisible();
// Zutaten aus dem JSON-LD sollten geparst sein.
await expect(page.getByText(/Hähnchenbrustfilet/i).first()).toBeVisible();
});
test('mit unparsbarer URL zeigt error-box', async ({ page }) => {
// google.com hat kein Recipe-JSON-LD -> Parser-Fehler.
const u = encodeURIComponent('https://www.google.com');
await page.goto(`/preview?url=${u}`);
await expect(page.getByRole('heading', { name: /kein Rezept/i })).toBeVisible({
timeout: 20000
});
});
});

View File

@@ -0,0 +1,40 @@
import { test, expect } from '@playwright/test';
import { clearActiveProfile, setActiveProfile, HENDRIK_ID } from './fixtures/profile';
test.describe('Profil', () => {
test('Switcher zeigt alle 3 Profile', async ({ page }) => {
await clearActiveProfile(page);
await page.goto('/');
await page.getByRole('button', { name: 'Profil wechseln' }).click();
await expect(page.getByText('Wer kocht heute?')).toBeVisible();
for (const name of ['Hendrik', 'Verena', 'Leana']) {
await expect(
page.locator('.profile-btn', { hasText: name })
).toBeVisible();
}
});
test('Profil-Auswahl persistiert im Header', async ({ page }) => {
await clearActiveProfile(page);
await page.goto('/');
await page.getByRole('button', { name: 'Profil wechseln' }).click();
await page.locator('.profile-btn', { hasText: 'Hendrik' }).click();
await expect(page.getByRole('button', { name: 'Profil wechseln' })).toContainText('Hendrik');
});
test('mit aktivem Profil: "Deine Favoriten"-Sektion erscheint', async ({ page }) => {
await setActiveProfile(page, HENDRIK_ID);
await page.goto('/');
await expect(
page.getByRole('heading', { level: 2, name: 'Deine Favoriten' })
).toBeVisible();
});
test('ohne Profil: Rating-Klick oeffnet Standard-Hinweis', async ({ page }) => {
await clearActiveProfile(page);
await page.goto('/recipes/66');
await page.getByRole('button', { name: '5 Sterne' }).click();
await expect(page.getByText('Kein Profil gewählt')).toBeVisible();
await expect(page.getByText(/klappt die Aktion/)).toBeVisible();
});
});

View File

@@ -0,0 +1,84 @@
import { test, expect } from '@playwright/test';
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
import {
clearFavorite,
clearRating,
removeFromWishlist
} from './fixtures/api-cleanup';
// Chicken Teriyaki auf kochwas-dev: 4 Portionen, 500 g Haehnchen, 100 ml Soja.
const RECIPE_ID = 66;
test.describe('Rezept-Detail', () => {
test.beforeEach(async ({ page }) => {
await setActiveProfile(page, HENDRIK_ID);
});
test.afterEach(async ({ request }) => {
await clearRating(request, RECIPE_ID, HENDRIK_ID);
await clearFavorite(request, RECIPE_ID, HENDRIK_ID);
await removeFromWishlist(request, RECIPE_ID, HENDRIK_ID);
});
test('Header + Zutaten sichtbar', async ({ page }) => {
await page.goto(`/recipes/${RECIPE_ID}`);
await expect(
page.getByRole('heading', { level: 1, name: /Chicken Teriyaki/i })
).toBeVisible();
await expect(page.getByText('Hähnchenbrustfilet').first()).toBeVisible();
});
test('Portionen-Scaler: 4 -> 6 skaliert Mengen proportional', async ({ page }) => {
await page.goto(`/recipes/${RECIPE_ID}`);
// Start: 4 Portionen, 500 g Haehnchen, 100 ml Soja.
await expect(page.locator('.srv-value strong').first()).toHaveText('4');
await page.getByRole('button', { name: 'Mehr' }).first().click();
await page.getByRole('button', { name: 'Mehr' }).first().click();
await expect(page.locator('.srv-value strong').first()).toHaveText('6');
// Skalierte Mengen 1.5x — ueber das Item-Name-Filter, robuster
// gegenueber Whitespace-Quirks zwischen <span class="qty">-Teilen.
await expect(
page.locator('.ing-list li', { hasText: 'Hähnchenbrustfilet' })
).toContainText('750 g');
await expect(
page.locator('.ing-list li', { hasText: 'Sojasauce' })
).toContainText('150 ml');
});
test('Favorit toggelt heart-Klasse sauber', async ({ page }) => {
await page.goto(`/recipes/${RECIPE_ID}`);
const favBtn = page.getByRole('button', { name: 'Favorit' });
await expect(favBtn).not.toHaveClass(/heart/);
await favBtn.click();
await expect(favBtn).toHaveClass(/heart/);
await favBtn.click();
await expect(favBtn).not.toHaveClass(/heart/);
});
test('Rating persistiert ueber Reload', async ({ page }) => {
await page.goto(`/recipes/${RECIPE_ID}`);
await page.getByRole('button', { name: '4 Sterne' }).click();
await expect(page.getByRole('button', { name: '4 Sterne' })).toHaveClass(/filled/);
await page.reload();
await expect(page.getByRole('button', { name: '4 Sterne' })).toHaveClass(/filled/);
});
test('Heute gekocht inkrementiert Counter', async ({ page }) => {
await page.goto(`/recipes/${RECIPE_ID}`);
const cookedBtn = page.getByRole('button', { name: /Heute gekocht/i });
const before = (await cookedBtn.innerText()).trim();
await cookedBtn.click();
// Der Button bekommt einen "(N)"-Suffix bzw. der existierende zaehler
// steigt. Wir pruefen nur, dass sich der Text aendert.
await expect(cookedBtn).not.toHaveText(before);
});
test('Auf Wunschliste-Toggle funktioniert', async ({ page }) => {
await page.goto(`/recipes/${RECIPE_ID}`);
const wishBtn = page.getByRole('button', { name: /Auf Wunschliste/i });
const initialLabel = (await wishBtn.getAttribute('aria-label')) ?? '';
await wishBtn.click();
// aria-label wechselt zwischen "setzen" und "Von der Wunschliste entfernen"
await expect(wishBtn).not.toHaveAttribute('aria-label', initialLabel);
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
test.describe('Suche', () => {
test('lokaler Treffer erscheint live beim Tippen', async ({ page }) => {
await page.goto('/');
await page.getByRole('searchbox', { name: 'Suchbegriff' }).fill('lasagne');
await expect(page.getByRole('link', { name: /Pfannen Lasagne/i })).toBeVisible({
timeout: 5000
});
});
test('Web-Fallback bei unbekanntem Begriff', async ({ page }) => {
// Direkt per URL — spart den Debounce-Timer.
await page.goto('/?q=pizza+margherita');
await expect(page.getByText(/Keine lokalen Rezepte/i)).toBeVisible({ timeout: 15000 });
// Mindestens ein Web-Treffer mit einer Domain-Labeling.
await expect(page.getByText(/chefkoch\.de|rezeptwelt\.de/i).first()).toBeVisible();
});
test('Nonsense-Query rendert Fallback ohne Crash', async ({ page }) => {
// SearXNG matcht loose — selbst Nonsense gibt oft Fuzzy-Treffer.
// Wir pruefen deshalb nur, dass die Seite sinnvoll reagiert
// (entweder echter Empty-State ODER Web-Fallback) und kein JS-Fehler
// fliegt.
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
await page.goto('/?q=xxyyzznotarecipexxxxxxxx');
await expect(
page.getByText(/Schaue unter den Topfdeckeln|Keine lokalen Rezepte/i)
).toBeVisible({ timeout: 15000 });
expect(errors).toEqual([]);
});
test('Deep-Link ?q=lasagne stellt Query im Input wieder her', async ({ page }) => {
await page.goto('/?q=lasagne');
const sb = page.getByRole('searchbox', { name: 'Suchbegriff' });
await expect(sb).toHaveValue('lasagne');
});
});

View File

@@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test';
import { clearActiveProfile, setActiveProfile, HENDRIK_ID } from './fixtures/profile';
test.describe('Wunschliste-Seite', () => {
test('laedt Header + Sort-Tabs', async ({ page }) => {
await setActiveProfile(page, HENDRIK_ID);
await page.goto('/wishlist');
await expect(page.getByRole('heading', { level: 1, name: 'Wunschliste' })).toBeVisible();
for (const label of ['Meist gewünscht', 'Neueste', 'Älteste']) {
await expect(page.getByRole('tab', { name: label })).toBeVisible();
}
});
test('Badge im Header stimmt mit Anzahl Eintraegen ueberein', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
await page.goto('/wishlist');
// Die API zaehlt die Wunschlisten-Rezepte — der Header-Badge sollte
// die gleiche Zahl zeigen.
const res = await request.get('/api/wishlist?sort=popular');
const body = (await res.json()) as { entries: unknown[] };
const expected = body.entries.length;
if (expected === 0) {
// Kein Badge bei Null — der Link hat dann gar keine Zahl.
return;
}
const badge = page.locator('a[href="/wishlist"]').first();
await expect(badge).toContainText(String(expected));
});
test('requireProfile zeigt Custom-Message "um mitzuwuenschen"', async ({ page }) => {
await clearActiveProfile(page);
await page.goto('/wishlist');
// Erster "Ich will das auch"-Button eines beliebigen Eintrags.
// Falls Wunschliste leer ist, ueberspringen.
const btn = page.getByRole('button', { name: /Ich will das auch/i }).first();
const count = await btn.count();
test.skip(count === 0, 'Wunschliste leer — Custom-Message-Test uebersprungen');
await btn.click();
await expect(page.getByText('Kein Profil gewählt')).toBeVisible();
await expect(page.getByText('um mitzuwünschen')).toBeVisible();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import sharp from 'sharp';
const { mockExtract } = vi.hoisted(() => ({ mockExtract: vi.fn() }));
vi.mock('$lib/server/ai/gemini-client', () => ({
extractRecipeFromImage: mockExtract,
GeminiError: class GeminiError extends Error {
constructor(
public readonly code: string,
message: string
) {
super(message);
this.name = 'GeminiError';
}
}
}));
import { POST } from '../../src/routes/api/recipes/extract-from-photo/+server';
import { GeminiError } from '$lib/server/ai/gemini-client';
async function makeJpeg(): Promise<Buffer> {
return sharp({
create: { width: 100, height: 100, channels: 3, background: '#888' }
})
.jpeg()
.toBuffer();
}
function mkEvent(body: FormData, ip = '1.2.3.4') {
return {
request: new Request('http://test/api/recipes/extract-from-photo', {
method: 'POST',
body
}),
getClientAddress: () => ip
};
}
const validAiResponse = {
title: 'Testrezept',
servings_default: 4,
servings_unit: 'Portionen',
prep_time_min: 10,
cook_time_min: 20,
total_time_min: null,
ingredients: [{ quantity: 1, unit: null, name: 'Apfel', note: null }],
steps: [{ text: 'Apfel schälen.' }]
};
beforeEach(() => {
mockExtract.mockReset();
process.env.GEMINI_API_KEY = 'test-key';
});
describe('POST /api/recipes/extract-from-photo', () => {
it('happy path: 200 with recipe shape', async () => {
mockExtract.mockResolvedValueOnce(validAiResponse);
const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }), 'x.jpg');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST(mkEvent(fd) as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.recipe.title).toBe('Testrezept');
expect(typeof body.recipe.description).toBe('string');
expect(body.recipe.description.length).toBeGreaterThan(0);
expect(body.recipe.image_path).toBeNull();
expect(body.recipe.ingredients[0].raw_text).toContain('Apfel');
expect(body.recipe.id).toBeNull();
});
it('413 when file exceeds 20 MB', async () => {
const big = Buffer.alloc(21 * 1024 * 1024);
const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST(mkEvent(fd, '1.1.1.1') as any);
expect(res.status).toBe(413);
expect((await res.json()).code).toBe('PAYLOAD_TOO_LARGE');
});
it('415 when content-type not in whitelist', async () => {
const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(Buffer.from('hi'))], { type: 'text/plain' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST(mkEvent(fd, '2.2.2.2') as any);
expect(res.status).toBe(415);
expect((await res.json()).code).toBe('UNSUPPORTED_MEDIA_TYPE');
});
it('400 when no photo field', async () => {
const fd = new FormData();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST(mkEvent(fd, '3.3.3.3') as any);
expect(res.status).toBe(400);
});
it('422 NO_RECIPE_IN_IMAGE when 0 ingredients AND 0 steps', async () => {
mockExtract.mockResolvedValueOnce({
...validAiResponse,
ingredients: [],
steps: []
});
const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST(mkEvent(fd, '4.4.4.4') as any);
expect(res.status).toBe(422);
expect((await res.json()).code).toBe('NO_RECIPE_IN_IMAGE');
});
it('503 AI_NOT_CONFIGURED when GeminiError thrown', async () => {
mockExtract.mockRejectedValueOnce(
new GeminiError('AI_NOT_CONFIGURED', 'no key')
);
const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST(mkEvent(fd, '5.5.5.5') as any);
expect(res.status).toBe(503);
expect((await res.json()).code).toBe('AI_NOT_CONFIGURED');
});
it('429 when rate limit exceeded for same IP', async () => {
mockExtract.mockResolvedValue(validAiResponse);
const ip = '9.9.9.9';
for (let i = 0; i < 10; i++) {
const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await POST(mkEvent(fd, ip) as any);
}
const last = new FormData();
last.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST(mkEvent(last, ip) as any);
expect(res.status).toBe(429);
});
});

View File

@@ -70,7 +70,8 @@ describe('recipe repository', () => {
unit: 'g',
name: 'Pancetta',
note: null,
raw_text: '200 g Pancetta'
raw_text: '200 g Pancetta',
section_heading: null
}
],
tags: ['Italienisch']
@@ -118,13 +119,13 @@ describe('recipe repository', () => {
baseRecipe({
title: 'Pasta',
ingredients: [
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta' }
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta', section_heading: null }
]
})
);
replaceIngredients(db, id, [
{ position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln' },
{ position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier' }
{ position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln', section_heading: null },
{ position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier', section_heading: null }
]);
const loaded = getRecipeById(db, id);
expect(loaded?.ingredients.length).toBe(2);
@@ -154,4 +155,31 @@ describe('recipe repository', () => {
const loaded = getRecipeById(db, id);
expect(loaded?.steps.map((s) => s.text)).toEqual(['Erst', 'Dann']);
});
it('persistiert section_heading und gibt es beim Laden zurueck', () => {
const db = openInMemoryForTest();
const recipe = baseRecipe({
title: 'Torte',
ingredients: [
{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Für den Teig' },
{ position: 2, quantity: 100, unit: 'g', name: 'Zucker', note: null, raw_text: '100 g Zucker', section_heading: null },
{ position: 3, quantity: 300, unit: 'g', name: 'Beeren', note: null, raw_text: '300 g Beeren', section_heading: 'Für die Füllung' }
]
});
const id = insertRecipe(db, recipe);
const loaded = getRecipeById(db, id);
expect(loaded!.ingredients[0].section_heading).toBe('Für den Teig');
expect(loaded!.ingredients[1].section_heading).toBeNull();
expect(loaded!.ingredients[2].section_heading).toBe('Für die Füllung');
});
it('replaceIngredients persistiert section_heading', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, baseRecipe({ title: 'X' }));
replaceIngredients(db, id, [
{ position: 1, quantity: null, unit: null, name: 'A', note: null, raw_text: 'A', section_heading: 'Kopf' }
]);
const loaded = getRecipeById(db, id);
expect(loaded!.ingredients[0].section_heading).toBe('Kopf');
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db';
const { testDb } = vi.hoisted(() => {
// Lazy holder; real DB instantiated in beforeEach.
return { testDb: { current: null as ReturnType<typeof openInMemoryForTest> | null } };
});
vi.mock('$lib/server/db', async () => {
const actual =
await vi.importActual<typeof import('../../src/lib/server/db')>(
'../../src/lib/server/db'
);
return {
...actual,
getDb: () => {
if (!testDb.current) throw new Error('test DB not initialised');
return testDb.current;
}
};
});
import { POST } from '../../src/routes/api/recipes/+server';
function mkReq(body: unknown) {
return {
request: new Request('http://test/api/recipes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
})
};
}
const validBody = {
title: 'Aus Foto erstellt',
description: 'Abrakadabra — Rezept da.',
servings_default: 4,
servings_unit: 'Portionen',
prep_time_min: 10,
cook_time_min: 20,
total_time_min: null,
ingredients: [
{
position: 1,
quantity: 1,
unit: null,
name: 'Apfel',
note: null,
raw_text: '1 Apfel',
section_heading: null
}
],
steps: [{ position: 1, text: 'Apfel schneiden.' }]
};
beforeEach(() => {
testDb.current = openInMemoryForTest();
});
describe('POST /api/recipes', () => {
it('happy path returns 201 + id', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST(mkReq(validBody) as any);
expect(res.status).toBe(201);
const body = await res.json();
expect(typeof body.id).toBe('number');
expect(body.id).toBeGreaterThan(0);
});
it('400 on empty title', async () => {
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST(mkReq({ ...validBody, title: '' }) as any)
).rejects.toMatchObject({ status: 400 });
});
it('400 on missing ingredients array', async () => {
const bad = { ...validBody } as Partial<typeof validBody>;
delete bad.ingredients;
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST(mkReq(bad) as any)
).rejects.toMatchObject({ status: 400 });
});
});

View File

@@ -48,7 +48,7 @@ describe('searchLocal', () => {
recipe({
title: 'Pasta',
ingredients: [
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '' }
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '', section_heading: null }
]
})
);

View File

@@ -6,14 +6,16 @@ describe('resolveStrategy', () => {
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('network-first for recipe HTML pages', () => {
expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('network-first');
});
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-first for recipe API reads', () => {
expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('network-first');
expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe(
'network-first'
);
expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('network-first');
});
it('network-only for write methods', () => {
@@ -34,8 +36,8 @@ describe('resolveStrategy', () => {
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');
it('falls through to network-first for other same-origin GETs (e.g. root page)', () => {
expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('network-first');
expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('network-first');
});
});

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { DESCRIPTION_PHRASES, pickRandomPhrase } from '../../src/lib/server/ai/description-phrases';
describe('description-phrases', () => {
it('contains exactly 50 entries', () => {
expect(DESCRIPTION_PHRASES).toHaveLength(50);
});
it('has no empty or whitespace-only entries', () => {
for (const phrase of DESCRIPTION_PHRASES) {
expect(phrase.trim().length).toBeGreaterThan(0);
}
});
it('has no duplicates', () => {
const set = new Set(DESCRIPTION_PHRASES);
expect(set.size).toBe(DESCRIPTION_PHRASES.length);
});
it('pickRandomPhrase returns a member of the pool', () => {
const pool = new Set(DESCRIPTION_PHRASES);
for (let i = 0; i < 100; i++) {
expect(pool.has(pickRandomPhrase())).toBe(true);
}
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockGenerateContent = vi.fn();
vi.mock('@google/generative-ai', async () => {
const actual = await vi.importActual<typeof import('@google/generative-ai')>(
'@google/generative-ai'
);
return {
...actual,
GoogleGenerativeAI: vi.fn().mockImplementation(() => ({
getGenerativeModel: () => ({ generateContent: mockGenerateContent })
}))
};
});
// $env/dynamic/private is mocked via SvelteKit's own mock; here we lean on
// process.env (the client falls back to it).
import {
extractRecipeFromImage,
GeminiError
} from '../../src/lib/server/ai/gemini-client';
beforeEach(() => {
mockGenerateContent.mockReset();
process.env.GEMINI_API_KEY = 'test-key';
process.env.GEMINI_MODEL = 'gemini-2.5-flash';
process.env.GEMINI_TIMEOUT_MS = '5000';
});
const validResponse = {
title: 'Apfelkuchen',
servings_default: 8,
servings_unit: 'Stück',
prep_time_min: 20,
cook_time_min: 45,
total_time_min: null,
ingredients: [{ quantity: 500, unit: 'g', name: 'Mehl', note: null }],
steps: [{ text: 'Ofen auf 180 °C vorheizen.' }]
};
describe('extractRecipeFromImage', () => {
it('happy path: returns parsed recipe data', async () => {
mockGenerateContent.mockResolvedValueOnce({
response: { text: () => JSON.stringify(validResponse) }
});
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
expect(result.title).toBe('Apfelkuchen');
expect(result.ingredients).toHaveLength(1);
});
it('retries once on schema-invalid JSON, then succeeds', async () => {
mockGenerateContent
.mockResolvedValueOnce({ response: { text: () => '{"title": "no arrays"}' } })
.mockResolvedValueOnce({
response: { text: () => JSON.stringify(validResponse) }
});
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
expect(result.title).toBe('Apfelkuchen');
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
});
it('throws AI_FAILED after schema-invalid + retry also invalid', async () => {
mockGenerateContent
.mockResolvedValueOnce({ response: { text: () => '{}' } })
.mockResolvedValueOnce({
response: { text: () => '{"title": "still bad"}' }
});
await expect(
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
).rejects.toMatchObject({ code: 'AI_FAILED' });
});
it('throws AI_RATE_LIMITED without retry on 429', async () => {
const err = new Error('429 Too Many Requests') as Error & { status?: number };
err.status = 429;
mockGenerateContent.mockRejectedValueOnce(err);
await expect(
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
).rejects.toMatchObject({ code: 'AI_RATE_LIMITED' });
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
});
it('retries once on 5xx, then succeeds', async () => {
const err = new Error('500 Server Error') as Error & { status?: number };
err.status = 500;
mockGenerateContent
.mockRejectedValueOnce(err)
.mockResolvedValueOnce({
response: { text: () => JSON.stringify(validResponse) }
});
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
expect(result.title).toBe('Apfelkuchen');
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
});
it('throws AI_FAILED after 5xx + retry also fails', async () => {
const err = new Error('500') as Error & { status?: number };
err.status = 500;
mockGenerateContent.mockRejectedValue(err);
await expect(
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
).rejects.toMatchObject({ code: 'AI_FAILED' });
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
});
it('throws AI_TIMEOUT when generateContent never resolves', async () => {
process.env.GEMINI_TIMEOUT_MS = '50';
mockGenerateContent.mockImplementation(
() => new Promise(() => {}) // never resolves
);
await expect(
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
).rejects.toMatchObject({ code: 'AI_TIMEOUT' });
});
it('throws AI_NOT_CONFIGURED when GEMINI_API_KEY is empty', async () => {
process.env.GEMINI_API_KEY = '';
await expect(
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
).rejects.toMatchObject({ code: 'AI_NOT_CONFIGURED' });
});
it('GeminiError has a code property', () => {
const e = new GeminiError('AI_FAILED', 'x');
expect(e.code).toBe('AI_FAILED');
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import sharp from 'sharp';
import { preprocessImage } from '../../src/lib/server/ai/image-preprocess';
async function makeTestImage(
width: number,
height: number,
format: 'jpeg' | 'png' | 'webp' = 'jpeg'
): Promise<Buffer> {
return sharp({
create: {
width,
height,
channels: 3,
background: { r: 128, g: 128, b: 128 }
}
})
.toFormat(format)
.toBuffer();
}
describe('preprocessImage', () => {
it('resizes a landscape image so long edge <= 1600px', async () => {
const input = await makeTestImage(4000, 2000);
const { buffer, mimeType } = await preprocessImage(input);
const meta = await sharp(buffer).metadata();
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600);
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeGreaterThan(1000);
expect(mimeType).toBe('image/jpeg');
});
it('resizes a portrait image so long edge <= 1600px', async () => {
const input = await makeTestImage(2000, 4000);
const { buffer } = await preprocessImage(input);
const meta = await sharp(buffer).metadata();
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600);
});
it('does not upscale smaller images', async () => {
const input = await makeTestImage(800, 600);
const { buffer } = await preprocessImage(input);
const meta = await sharp(buffer).metadata();
expect(meta.width).toBe(800);
expect(meta.height).toBe(600);
});
it('converts PNG input to JPEG output', async () => {
const input = await makeTestImage(1000, 1000, 'png');
const { buffer, mimeType } = await preprocessImage(input);
const meta = await sharp(buffer).metadata();
expect(meta.format).toBe('jpeg');
expect(mimeType).toBe('image/jpeg');
});
it('strips EXIF metadata', async () => {
const input = await sharp({
create: { width: 100, height: 100, channels: 3, background: '#888' }
})
.withMetadata({ exif: { IFD0: { Copyright: 'test' } } })
.jpeg()
.toBuffer();
const { buffer } = await preprocessImage(input);
const meta = await sharp(buffer).metadata();
expect(meta.exif).toBeUndefined();
});
it('rejects non-image buffers', async () => {
const notAnImage = Buffer.from('hello world');
await expect(preprocessImage(notAnImage)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from 'vitest';
import { PhotoUploadStore } from '../../src/lib/client/photo-upload.svelte';
const validRecipe = {
id: null,
title: 'T',
description: 'D',
source_url: null,
source_domain: null,
image_path: null,
servings_default: null,
servings_unit: null,
prep_time_min: null,
cook_time_min: null,
total_time_min: null,
cuisine: null,
category: null,
ingredients: [],
steps: [{ position: 1, text: 'S' }],
tags: []
};
function fakeFetch(responses: Response[]): typeof fetch {
let i = 0;
return vi.fn(async () => responses[i++]) as unknown as typeof fetch;
}
function mkFile(): File {
return new File([new Uint8Array([1, 2, 3])], 'x.jpg', { type: 'image/jpeg' });
}
describe('PhotoUploadStore', () => {
it('starts in idle', () => {
const s = new PhotoUploadStore();
expect(s.status).toBe('idle');
});
it('transitions loading → success on happy path', async () => {
const s = new PhotoUploadStore({
fetchImpl: fakeFetch([
new Response(JSON.stringify({ recipe: validRecipe }), { status: 200 })
])
});
await s.upload(mkFile());
expect(s.status).toBe('success');
expect(s.recipe?.title).toBe('T');
});
it('transitions to error with code on 422', async () => {
const s = new PhotoUploadStore({
fetchImpl: fakeFetch([
new Response(
JSON.stringify({ code: 'NO_RECIPE_IN_IMAGE', message: 'nope' }),
{ status: 422 }
)
])
});
await s.upload(mkFile());
expect(s.status).toBe('error');
expect(s.errorCode).toBe('NO_RECIPE_IN_IMAGE');
});
it('reset() brings store back to idle', async () => {
const s = new PhotoUploadStore({
fetchImpl: fakeFetch([new Response('{"code":"X"}', { status: 503 })])
});
await s.upload(mkFile());
expect(s.status).toBe('error');
s.reset();
expect(s.status).toBe('idle');
expect(s.errorCode).toBeNull();
expect(s.lastFile).toBeNull();
});
it('retry re-uploads lastFile', async () => {
const s = new PhotoUploadStore({
fetchImpl: fakeFetch([
new Response('{"code":"X"}', { status: 503 }),
new Response(JSON.stringify({ recipe: validRecipe }), { status: 200 })
])
});
await s.upload(mkFile());
expect(s.status).toBe('error');
await s.retry();
expect(s.status).toBe('success');
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRateLimiter } from '../../src/lib/server/ai/rate-limit';
describe('rate-limit', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('allows first 10 requests, rejects 11th', () => {
const limiter = createRateLimiter({ windowMs: 60_000, max: 10 });
for (let i = 0; i < 10; i++) expect(limiter.check('1.2.3.4')).toBe(true);
expect(limiter.check('1.2.3.4')).toBe(false);
});
it('tracks per-IP independently', () => {
const limiter = createRateLimiter({ windowMs: 60_000, max: 2 });
expect(limiter.check('a')).toBe(true);
expect(limiter.check('a')).toBe(true);
expect(limiter.check('a')).toBe(false);
expect(limiter.check('b')).toBe(true);
});
it('resets after window elapses', () => {
const limiter = createRateLimiter({ windowMs: 1000, max: 1 });
expect(limiter.check('x')).toBe(true);
expect(limiter.check('x')).toBe(false);
vi.advanceTimersByTime(1001);
expect(limiter.check('x')).toBe(true);
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import {
RECIPE_EXTRACTION_SYSTEM_PROMPT,
GEMINI_RESPONSE_SCHEMA,
extractionResponseSchema,
type ExtractionResponse
} from '../../src/lib/server/ai/recipe-extraction-prompt';
describe('recipe-extraction-prompt', () => {
it('system prompt is in German and mentions Rezept + Zutaten + Zubereitung', () => {
const p = RECIPE_EXTRACTION_SYSTEM_PROMPT.toLowerCase();
expect(p).toContain('rezept');
expect(p).toContain('zutaten');
expect(p).toContain('zubereitung');
});
it('Gemini response schema has required top-level keys', () => {
expect(GEMINI_RESPONSE_SCHEMA.type).toBeDefined();
expect(Object.keys(GEMINI_RESPONSE_SCHEMA.properties)).toEqual(
expect.arrayContaining(['title', 'ingredients', 'steps'])
);
});
it('Zod validator accepts a well-formed response', () => {
const good: ExtractionResponse = {
title: 'Testrezept',
servings_default: 4,
servings_unit: 'Portionen',
prep_time_min: 15,
cook_time_min: 30,
total_time_min: null,
ingredients: [{ quantity: 100, unit: 'g', name: 'Mehl', note: null }],
steps: [{ text: 'Mehl in eine Schüssel geben.' }]
};
expect(() => extractionResponseSchema.parse(good)).not.toThrow();
});
it('Zod validator rejects missing title', () => {
const bad = { servings_default: 4, ingredients: [], steps: [] };
expect(() => extractionResponseSchema.parse(bad)).toThrow();
});
it('Zod validator accepts quantity=null and unit=null', () => {
const ok: ExtractionResponse = {
title: 'Prise-Rezept',
servings_default: null,
servings_unit: null,
prep_time_min: null,
cook_time_min: null,
total_time_min: null,
ingredients: [{ quantity: null, unit: null, name: 'Salz', note: 'nach Geschmack' }],
steps: [{ text: 'Einfach so.' }]
};
expect(() => extractionResponseSchema.parse(ok)).not.toThrow();
});
it('Zod validator rejects unexpected extra top-level keys (strict)', () => {
const bad = {
title: 'x',
servings_default: null,
servings_unit: null,
prep_time_min: null,
cook_time_min: null,
total_time_min: null,
ingredients: [],
steps: [],
malicious_extra_field: 'pwned'
};
expect(() => extractionResponseSchema.parse(bad)).toThrow();
});
});

View File

@@ -8,7 +8,8 @@ const mk = (q: number | null, unit: string | null, name: string): Ingredient =>
unit,
name,
note: null,
raw_text: ''
raw_text: '',
section_heading: null
});
describe('roundQuantity', () => {
@@ -40,4 +41,15 @@ describe('scaleIngredients', () => {
const scaled = scaleIngredients([mk(100, 'g', 'Butter')], 1 / 3);
expect(scaled[0].quantity).toBe(33);
});
it('preserves section_heading through scaling', () => {
const input: Ingredient[] = [
{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Teig' },
{ position: 2, quantity: null, unit: null, name: 'Ei', note: null, raw_text: 'Ei', section_heading: null }
];
const scaled = scaleIngredients(input, 2);
expect(scaled[0].section_heading).toBe('Teig');
expect(scaled[1].section_heading).toBeNull();
expect(scaled[0].quantity).toBe(400);
});
});

View File

@@ -0,0 +1,264 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SearchStore, type SearchSnapshot } from '../../src/lib/client/search.svelte';
type FetchMock = ReturnType<typeof vi.fn>;
function mockFetch(responses: Array<{ ok?: boolean; status?: number; body: unknown }>): FetchMock {
const calls = [...responses];
return vi.fn(async () => {
const r = calls.shift();
if (!r) throw new Error('fetch called more times than expected');
return {
ok: r.ok ?? true,
status: r.status ?? 200,
json: async () => r.body
} as Response;
});
}
describe('SearchStore', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('keeps results empty while query is <= 3 chars (debounced)', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([]);
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
store.query = 'abc';
store.runDebounced();
await vi.advanceTimersByTimeAsync(100);
expect(store.searching).toBe(false);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('fires local search after debounce when query > 3 chars', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [{ id: 1, title: 'Pasta', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 50, pageSize: 30 });
store.query = 'pasta';
store.runDebounced();
expect(store.searching).toBe(true);
await vi.advanceTimersByTimeAsync(100);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled());
expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?q=pasta&limit=30/);
expect(store.hits).toHaveLength(1);
expect(store.searchedFor).toBe('pasta');
expect(store.localExhausted).toBe(true);
});
it('falls back to web search when local returns zero hits', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ body: { hits: [{ url: 'https://chefkoch.de/x', title: 'Foo', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
store.query = 'pizza';
store.runDebounced();
await vi.advanceTimersByTimeAsync(100);
await vi.waitFor(() => expect(store.webHits).toHaveLength(1));
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?q=pizza&pageno=1/);
expect(store.webPageno).toBe(1);
});
it('race-guard: stale fetch response discarded when query was cleared/changed', async () => {
vi.useFakeTimers();
let resolveFetch!: (v: Response) => void;
const fetchImpl = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
})
);
const store = new SearchStore({ fetchImpl, debounceMs: 10 });
store.query = 'stale-query';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
expect(fetchImpl).toHaveBeenCalledTimes(1);
// User keeps typing BEFORE the response arrives — race-guard should kick in
// when the fetch finally resolves.
store.query = 'different';
resolveFetch({
ok: true,
status: 200,
json: async () => ({
hits: [
{
id: 99,
title: 'Stale',
description: null,
image_path: null,
source_domain: null,
avg_stars: null,
last_cooked_at: null
}
]
})
} as Response);
// Flush microtasks so the awaited response + race-guard run.
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
await Promise.resolve();
expect(store.hits).toEqual([]);
expect(store.searchedFor).toBeNull();
});
it('loadMore: drains local first (offset pagination)', async () => {
vi.useFakeTimers();
const page1 = Array.from({ length: 30 }, (_, i) => ({ id: i, title: `r${i}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }));
const page2 = Array.from({ length: 5 }, (_, i) => ({ id: i + 30, title: `r${i + 30}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }));
const fetchImpl = mockFetch([
{ body: { hits: page1 } },
{ body: { hits: page2 } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 });
store.query = 'meal';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(store.hits).toHaveLength(30));
expect(store.localExhausted).toBe(false);
await store.loadMore();
expect(store.hits).toHaveLength(35);
expect(fetchImpl.mock.calls[1][0]).toMatch(/offset=30/);
expect(store.localExhausted).toBe(true);
});
it('loadMore: switches to web pagination after local exhausted', async () => {
vi.useFakeTimers();
const local = [{ id: 1, title: 'local', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }];
const webP1 = [{ url: 'https://a.com', title: 'A', domain: 'a.com', snippet: null, thumbnail: null }];
const webP2 = [{ url: 'https://b.com', title: 'B', domain: 'b.com', snippet: null, thumbnail: null }];
const fetchImpl = mockFetch([
{ body: { hits: local } },
{ body: { hits: webP1 } },
{ body: { hits: webP2 } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 });
store.query = 'soup';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
expect(store.localExhausted).toBe(true);
await store.loadMore();
expect(store.webHits).toHaveLength(1);
await store.loadMore();
expect(store.webHits).toHaveLength(2);
expect(store.webPageno).toBe(2);
});
it('web search error sets webError and marks webExhausted', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ ok: false, status: 502, body: { message: 'SearXNG unreachable' } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 10 });
store.query = 'anything';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(store.webError).toBe('SearXNG unreachable'));
expect(store.webExhausted).toBe(true);
});
it('reset(): clears query, results, and pending debounce', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([]);
const store = new SearchStore({ fetchImpl, debounceMs: 100 });
store.query = 'foobar';
store.runDebounced();
store.reset();
await vi.advanceTimersByTimeAsync(200);
expect(store.query).toBe('');
expect(store.hits).toEqual([]);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('captureSnapshot / restoreSnapshot: round-trips without re-fetching', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([]);
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
const snap: SearchSnapshot = {
query: 'lasagne',
hits: [{ id: 7, title: 'Lasagne', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }],
webHits: [],
searchedFor: 'lasagne',
webError: null,
localExhausted: true,
webPageno: 0,
webExhausted: false
};
store.restoreSnapshot(snap);
expect(store.query).toBe('lasagne');
expect(store.hits).toHaveLength(1);
store.runDebounced();
await vi.advanceTimersByTimeAsync(100);
expect(fetchImpl).not.toHaveBeenCalled();
const round = store.captureSnapshot();
expect(round).toEqual(snap);
});
it('filterParam option: gets appended to both local and web requests', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ body: { hits: [] } }
]);
const store = new SearchStore({
fetchImpl,
debounceMs: 10,
filterParam: () => '&domains=chefkoch.de'
});
store.query = 'curry';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/);
expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/);
});
it('runSearch(q) cancels pending debounce to avoid double-fetch', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [{ id: 1, title: 'immediate', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
]);
const store = new SearchStore({ fetchImpl, debounceMs: 300 });
store.query = 'meal';
store.runDebounced(); // schedules the 300ms timer
// Before the timer fires, call runSearch immediately (e.g. form submit).
await store.runSearch('meal');
expect(fetchImpl).toHaveBeenCalledTimes(1);
// Now advance past the original debounce — timer must not still fire.
await vi.advanceTimersByTimeAsync(400);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('reSearch: immediate re-run with current query on filter change', async () => {
vi.useFakeTimers();
let filter = '';
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ body: { hits: [] } },
{ body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
]);
const store = new SearchStore({
fetchImpl,
debounceMs: 10,
filterDebounceMs: 5,
filterParam: () => filter
});
store.query = 'broth';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
filter = '&domains=chefkoch.de';
store.reSearch();
await vi.advanceTimersByTimeAsync(10);
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
expect(last).toMatch(/&domains=chefkoch\.de/);
});
});

View File

@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
// sharp muss extern bleiben: der Server-Bundle-Schritt kann sharp's
// dynamic-require fuer die native .node-Binary nicht aufloesen. Wenn
// sharp nicht gebundelt wird, laedt Node es zur Laufzeit regulaer aus
// node_modules/@img/sharp-linuxmusl-arm64, das dann funktioniert.
ssr: {
external: ['sharp']
},
test: {
include: ['tests/**/*.test.ts'],
globals: false,