111 Commits
v1.2.0 ... main

Author SHA1 Message Date
hsiegeln
91fbf27269 chore: bump package.json + package-lock auf 1.4.2
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m52s
Patch-Release: Einkaufsliste-Konsolidierung ueber Einheiten (500 g + 1 kg
Kartoffeln → 1,5 kg). Migration 015 fuer Check-Key-Family-Stabilitaet.
formatQuantity app-weit auf de-DE Komma-Dezimal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:16:04 +02:00
hsiegeln
b556eb39b3 chore(shopping): stale Kommentar in clearCheckedItems entfernt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m13s
Der alte Satz beschrieb die ersetzte SQL-EXISTS-Variante; der neue
erklaert den aktuellen JS-basierten Family-Lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:11:02 +02:00
hsiegeln
c177c1dc5f feat(shopping): clearCheckedItems auf Family-Key umgestellt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m15s
Fix A: checked-Status in clearCheckedItems per JS-Lookup mit unitFamily()
statt SQL-EXISTS gegen raw unit_key berechnen.
Fix B: Orphan-Cleanup activeSet nutzt jetzt unitFamily(raw-unit) als Key,
sodass Checks mit family-key ('weight', 'volume') korrekt gematcht werden.
Neue Integrationstests bestaetigen Round-Trip und Orphan-Bereinigung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:08:28 +02:00
hsiegeln
b2337a5c2a refactor(shopping): listShoppingList - Pipe-safe name/family lookup
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
Liest nameKey und familyKey direkt von members[0] statt den
Composite-Key am Pipe-Zeichen zu splitten. Verhindert falsche
Dekodierung bei Zutaten wie "Fleisch- | Wurstwaren".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:05:53 +02:00
hsiegeln
f2656bd9e3 feat(shopping): listShoppingList konsolidiert g/kg + ml/l
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
TS-seitige Family-Gruppierung via unitFamily() + consolidate() ersetzt
die reine SQL-Aggregation. unit_key im ShoppingListRow traegt jetzt den
Family-Key ('weight', 'volume' oder raw-unit). toggleCheck-Aufrufe und
unit_key-Assertions in den Tests entsprechend angepasst.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:02:27 +02:00
hsiegeln
fd55a44bfb feat(shopping): Migration 015 — Check-Keys auf Unit-Family
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m13s
unit_key in shopping_cart_check wird von Roheinheit (g, kg, ml, l)
auf Family-Key (weight, volume) umgestellt, damit Abhaks stabil
bleiben wenn die Display-Einheit wechselt. Entstehende Duplikate
werden durch Behalten des juengsten rowid dedupliziert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:58:37 +02:00
hsiegeln
14cf1b1d35 feat(format): formatQuantity app-weit auf de-DE Komma-Dezimal
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m19s
Ersetzt Math.round/toFixed-Logik durch q.toLocaleString('de-DE', …).
Dezimaltrennzeichen ist jetzt konsistent ein Komma (0,5 statt 0.5).
Tests aktualisiert; alle 316 Tests + svelte-check grün.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:55:29 +02:00
hsiegeln
b85f869c09 refactor(shopping): redundanten kg-Check in consolidate() entfernt + Boundary-Test
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Weight-Branch prueft nicht mehr doppelt auf unitFamily; stil-parity mit Volume-Branch.
Boundary-Test fuer exakt 1000 g ergaenzt (15/15 Tests gruen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:53:29 +02:00
hsiegeln
c6a549699a feat(shopping): consolidate() fuer g/kg + ml/l Summierung
Implementiert consolidate() in unit-consolidation.ts: summiert Mengen
innerhalb einer Unit-Family (Gewicht g/kg, Volumen ml/l) mit automatischer
Promotion ab Schwellwert; nicht-family-units werden direkt summiert.
quantity=null wird als 0 behandelt; alle-null ergibt null-Ergebnis.
9 neue Tests, alle 14 Tests gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:50:24 +02:00
hsiegeln
29f0245ce0 feat(shopping): unitFamily-Utility fuer Konsolidierung
Neue Hilfsfunktion `unitFamily` normalisiert Einheiten auf eine
Familien-Kennung ('weight', 'volume' oder lowercase-trim). Wird
in nachfolgenden Konsolidierungs-Schritten der Einkaufsliste
verwendet. Abgedeckt durch 5 Vitest-Unit-Tests (TDD).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:46:43 +02:00
hsiegeln
59b232c5fc docs(plan): Implementation-Plan fuer Einkaufsliste-Konsolidierung
7 Tasks (TDD, atomic commits):
1. unitFamily + Unit-Tests
2. consolidate + Tests fuer alle Edge-Cases
3. formatQuantity auf toLocaleString('de-DE', ...)
4. Migration 015 — Check-Keys auf Family
5. listShoppingList konsolidiert via TS-side grouping
6. clearCheckedItems + toggleCheck auf Family-Key
7. E2E-Smoke im Dev-Deployment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:44:18 +02:00
hsiegeln
b9b06e161c docs(spec): Einkaufsliste Mengen-Konsolidierung ueber Einheiten
Design fuer g/kg + ml/l Konsolidierung in listShoppingList():
500 g + 1 kg Kartoffeln aus verschiedenen Rezepten → 1,5 kg.

- unit-consolidation.ts mit unitFamily + consolidate
- GROUP BY wechselt auf family-key (weight/volume/raw)
- formatQuantity auf toLocaleString('de-DE', ...) app-weit
- Migration 015: shopping_cart_check.unit_key → family-key,
  bestehende Abhaks migriert, Duplikate dedupliziert
- Test-Coverage fuer alle Edge-Cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:39:47 +02:00
hsiegeln
2f0a45f487 chore: bump package.json + package-lock auf 1.4.1
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m59s
Patch-Release: fix fuer $effect-Loop auf der Startseite (sort=viewed)
und Wunschliste-Card-Layout auf Mobile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:24:58 +02:00
hsiegeln
a68b99c807 fix(wishlist): 2-Spalten-Grid auf Mobile statt stacked Footer
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m17s
Der stacked Card-Footer liess unter dem Bild (96x96) eine tote
Weissflaeche entstehen — Buttons rechts unten, links leer, unaufgeraeumt.

Neues Layout auf <=600px: Card ist 2-Spalten-Grid (96px | 1fr), Bild
spannt vertikal ueber alle Rows, rechts stapeln sich Titel -> Meta ->
Actions direkt untereinander. `display: contents` auf .body/.text zieht
die DOM-Kinder ohne Markup-Umbau in die Grid-Cells.

Ergebnis: Card-Hoehe orientiert sich am Content, keine toten Zonen,
Bild fuellt seinen Streifen vertikal, Buttons sitzen eng unter der Meta
(0.5rem padding) — tap-friendly ohne Kleben.

Getestet: svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:05:28 +02:00
hsiegeln
2573f80940 style(wishlist): Actions als Card-Footer ohne Trenner
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m15s
Auf Mobile wirkten die Buttons in der abgesetzten grauen Row mit
border-top etwas verloren. Nun sitzen sie im gleichen weissen
Card-Background ohne Border/Background-Wechsel — das Footer-Padding
reicht als visuelle Trennung, die Buttons gehoeren erkennbar zur Card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:50:12 +02:00
hsiegeln
0a97ea2fea fix(wishlist): Card stacked auf Mobile, Titel-Overflow behoben
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m15s
Auf schmalen Viewports (~390px) ueberlagerten die drei Action-Buttons
den Titel: .text reservierte 170px padding-right, aber nach 96px Bild
+ Gaps blieb kaum Platz fuer den Titel — lange Woerter wie
"Spaetzle-Pfanne" liefen hinter die Buttons.

Fix: @media (max-width: 600px) — Card wird flex-direction:column,
Actions-Row rutscht aus position:absolute in eine statische Reihe mit
border-top unter dem Body, full-width. Zusaetzlich overflow-wrap +
word-break als Safety-Net gegen bindestrich-gefuellte Monstertitel.

Desktop-Layout unveraendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:39:12 +02:00
hsiegeln
12f499cb98 fix(home): $effect-Loop bei sort=viewed via untrack
Der Profile-Switch-Refetch-Effect las allLoading in der sync tracking-
Phase. Der await fetch beendete die Sync-Phase, das finale
allLoading = false im finally lief ausserhalb → wurde als externer
Write interpretiert → Effect rerun → naechster Fetch → Endlosschleife.

2136 GETs auf /api/recipes/all?sort=viewed in 8s beobachtet.

Fix: nur profileStore.active bleibt tracked (der tatsaechliche
Trigger). allSort/allLoading werden in untrack() gelesen — die Writes
auf allLoading im finally triggern damit keinen Effect-Rerun mehr.

Verifiziert lokal: 1 Request statt 2000+ bei mount mit allSort=viewed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:39:02 +02:00
hsiegeln
829850aa88 chore: bump package.json + package-lock auf 1.4.0
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m21s
Tag v1.4.0 ist schon gesetzt (auf 2b0bd4d), der synchrone Version-Bump
in package.json und package-lock.json wurde dabei vergessen. Mit dem
Pattern aus vorigen Releases (v1.2.0/v1.3.0) wieder konsistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:58:45 +02:00
hsiegeln
2b0bd4dc44 fix(recipe): View-Beacon ueber \$effect statt onMount
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 33s
Live-Test auf kochwas-dev: bei Hard-Reload/Cold-Start (nicht SPA-Click)
landete kein view-Eintrag in der DB. Ursache: Recipe-Page-onMount
feuert vor Layout-onMount, profileStore.load() laeuft aber im Layout-
onMount und macht erst danach einen async fetch — zum Zeitpunkt des
Beacons war profileStore.active noch null.

Loesung: Beacon im \$effect, das auf profileStore.active reagiert.
viewBeaconSent-Flag verhindert duplicate POSTs wenn der User waehrend
der Page-Lifetime das Profil wechselt — der ursprueglich getrackte
Profil-View bleibt der "richtige" fuer dieses Page-Open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:50:54 +02:00
hsiegeln
e7318164cb fix(home): focus-visible auf section-head + scoped chev-CSS
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
Code-Review zu commit 2216c89: button hatte keinen :focus-visible
Outline (Safari zeigt sonst gar nichts an) — Pattern aus dem Rest
der Page uebernommen (#2b6a3d Outline). Globale .chev-Selektoren
unter .section-head gescoped, damit andere Komponenten den Klassen-
Namen kuenftig wiederverwenden koennen ohne Konflikte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:42:44 +02:00
hsiegeln
2216c89a04 feat(home): Collapsible Sections fuer Favoriten + Zuletzt hinzugefuegt
Header als <button> mit Chevron + Count-Pill, slide-Transition (180ms).
State in localStorage unter kochwas.collapsed.sections — JSON-Map
{favorites, recent}, default beide offen, corrupt-JSON faellt auf
Default zurueck.

Alle Rezepte bleibt absichtlich nicht-collapsibel — Hauptliste, immer
sichtbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:38:43 +02:00
hsiegeln
01d29bff0e feat(home): Sort-Chip 'Zuletzt angesehen' + Profile-Switch-Refetch
Neuer Wert 'viewed' im AllSort-Enum + ALL_SORTS-Array. localStorage-
Whitelist ergaenzt. Reactive $effect lauscht auf profileStore.active
und refetcht offset=0 nur wenn aktueller Sort 'viewed' ist — andere
Sortierungen sind profilunabhaengig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:35:28 +02:00
hsiegeln
a5321d620a feat(home): profile_id in alle /api/recipes/all-Fetches
buildAllUrl-Helper haengt profile_id an wenn ein Profil aktiv ist;
nutzt es loadAllMore, setAllSort und rehydrateAll. Voraussetzung fuer
sort=viewed (Server braucht profile_id fuer den View-Join).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:33:42 +02:00
hsiegeln
b31223add5 feat(api): /api/recipes/all akzeptiert sort=viewed + profile_id
VALID_SORTS um 'viewed' erweitert. parseProfileId-Helper analog zu
/api/wishlist. Wert wird an listAllRecipesPaginated als 5. Param
durchgereicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:31:17 +02:00
hsiegeln
f495c024c6 feat(recipe): View-Beacon beim oeffnen der Detailseite
Fire-and-forget POST /api/recipes/[id]/view in onMount, nur wenn
profileStore.active gesetzt. Schreibt last_viewed_at fuers Profil —
Voraussetzung fuer den Sort 'Zuletzt angesehen' auf der Hauptseite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:28:32 +02:00
hsiegeln
1214b9e01d refactor(api): parsePositiveIntParam fuer view-endpoint
Code-Review-Finding zu commit 82d4348: das Sibling-Endpoint
recipes/[id]/+server.ts nutzt schon parsePositiveIntParam aus
api-helpers.ts. Der neue View-Endpoint hatte die Logik inline
nachgebaut — jetzt aufgeraeumt fuer Konsistenz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:27:10 +02:00
hsiegeln
82d4348873 feat(api): POST /api/recipes/[id]/view fuer View-Beacon
Body { profile_id } via zod validiert. FK-Violation (unbekanntes
Profil oder Rezept) wird zu 404 normalisiert. Erfolg liefert 204
ohne Body — fire-and-forget vom Client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:23:36 +02:00
hsiegeln
6f54b004ca test(views): NULL-Tiebreaker explizit verifizieren
Code-Review-Finding zu commit 226ca5e: vorheriges Test seedete nur ein
NULL-Recipe, der alphabetische Tiebreaker fuer ungesehene Eintraege
wurde nicht exerciert. Zweites ungesehenes Rezept mit anderer
Einsatzreihenfolge ergaenzt — beweist dass Donauwelle vor
Zwiebelkuchen kommt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:20:51 +02:00
hsiegeln
226ca5e5ed feat(search): sort=viewed in listAllRecipesPaginated
Neuer Sort 'viewed' macht LEFT JOIN gegen recipe_view, ordert nach
last_viewed_at DESC mit alphabetischem Tiebreaker. NULL-Recipes (nie
angesehen) landen alphabetisch sortiert hinter den angesehenen
(CASE-NULL-last statt SQLite 3.30+ NULLS LAST).

Ohne profileId faellt der Sort auf alphabetisch zurueck — Sort-Chip
bleibt klickbar, ergibt aber sinnvolles Default-Verhalten ohne
aktiviertes Profil.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:17:17 +02:00
hsiegeln
5357c9787b refactor(views): ON CONFLICT DO UPDATE statt INSERT OR REPLACE
Code-Review-Finding zu commit 6c8de6f: INSERT OR REPLACE ist intern
DELETE+INSERT, das wuerde eventuelle FK-Children kuenftig stillschweigend
mitloeschen. ON CONFLICT DO UPDATE bumpt nur das Timestamp-Feld und
matcht den Stil der anderen Repos (shopping/repository.ts:43).

Migration-Dateiname zu recipe_view (singular) angeglichen, matcht
jetzt den Tabellennamen aus 543008b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:14:44 +02:00
hsiegeln
6c8de6fa3a feat(db): recordView/listViews fuer recipe_view
INSERT OR REPLACE fuer idempotenten Bump des last_viewed_at Timestamps.
listViews-Helper nur fuer Tests; Sort-Query laeuft direkt in
listAllRecipesPaginated via LEFT JOIN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:10:52 +02:00
hsiegeln
866a222265 docs: plan/spec auf recipe_view (singular) angeglichen
Tabellen-Konvention im Repo ist singular — siehe Code-Review-Findings
zu Task 1 (commit 543008b). Plan und Spec angeglichen damit weitere
Tasks nicht mit dem alten Plural arbeiten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:08:54 +02:00
hsiegeln
543008b0f2 refactor(db): recipe_views -> recipe_view, TIMESTAMP-Konsistenz
Code-Review-Findings nachgezogen: Tabellen-Konvention im Repo ist
singular (profile, recipe, favorite, cooking_log, thumbnail_cache),
deshalb recipe_view statt recipe_views; Index analog umbenannt.
last_viewed_at auf TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
gewechselt — matcht den Rest des Schemas. Header-Kommentar +
notnull-Assertion fuer recipe_id ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:08:17 +02:00
hsiegeln
2cd9b47450 feat(db): recipe_views table mit Profil-FK und Recent-Index
Tracking-Tabelle fuer Sort-Option Zuletzt angesehen. Composite-PK
(profile_id, recipe_id) erlaubt INSERT OR REPLACE per Default-Timestamp.
Index nach profile_id + last_viewed_at DESC fuer Sort-Query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:04:27 +02:00
hsiegeln
98894bb895 docs(plan): Implementation-Plan fuer Views-Sort + Collapsibles
10 Tasks (Migration -> Repo -> Sort-Branch -> API -> Beacon -> URL-
Helper -> Sort-Chip + Reactive Refetch -> Collapsibles -> Push&Verify)
mit TDD-Schritten, exakten Filepfaden und vollstaendigem Code in
jedem Step.

Spec-Migrationsnummer auf 014 korrigiert (war 010 — letzte aktuelle
Migration ist 013_shopping_list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:00:22 +02:00
hsiegeln
363ea6fbe7 docs(spec): Hauptseite Zuletzt-angesehen-Sort + Collapsibles
Spec fuer zwei Hauptseite-Features aus Brainstorming am 2026-04-22:

1) Neue Sort-Option "Zuletzt angesehen" fuer "Alle Rezepte". Tracking
   per Profil in neuer SQLite-Tabelle recipe_views, beim Laden der
   Detail-Seite per Beacon (POST /api/recipes/[id]/view) gesetzt.
   Server-Sort macht LEFT JOIN mit ORDER BY last_viewed_at DESC NULLS
   LAST, alphabetischer Tiebreaker.

2) "Deine Favoriten" und "Zuletzt hinzugefuegt" auf-/zuklappbar.
   Default offen, User-Wahl persistiert in localStorage pro Device.
   Header als button mit Chevron + Count-Pill, slide-Transition.
   "Alle Rezepte" bleibt nicht-collapsibel (Hauptliste).

Spec deckt Schema, API-Endpoint, DB-Layer, Markup-Pattern,
Reactive-Refetch bei Profile-Switch, Snapshot-Kompatibilitaet (rehydrate
muss profile_id mitbekommen), Test-Strategie und Reihenfolge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:54:52 +02:00
hsiegeln
005c3ea7b5 fix(home): rehydrate-Trigger aus snapshot.restore, nicht ueber onMount
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
Live-Test mit Fetch-Hook auf kochwas-dev hat bewiesen: bei tiefem
Endless-Scroll (60 Items) und Back-Nav fired rehydrateAll nie. Der
Logger zeigte nur limit=10&offset=0 plus IO-Trigger (10&offset=10).

Root Cause: SvelteKit ruft snapshot.restore *nach* onMount (post-mount
tick). Der vorherige Code parkte die Tiefe in pendingPagination und
liess onMount entscheiden — onMount lief aber zuerst, sah null, fiel
auf loadAllMore() zurueck. Restore setzte danach pendingPagination,
aber niemand las es mehr.

Fix: rehydrateAll direkt aus restore aufrufen (fire-and-forget).
onMount macht unkonditional loadAllMore() fuer den Fresh-Mount-Fall;
beide Pfade greifen ueber das allLoading-Flag bzw. ueber den
allRecipes-Overwrite (rehydrateAll's groesseres Result gewinnt
spaeter). Wasted-Fetch im Worst-Case: 10 Items (~2 KB) — vertretbar.

pendingPagination raus, onMount-Conditional vereinfacht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:59:11 +02:00
hsiegeln
1d7731edbb chore: ci-log.txt und .claude/ aus dem Repo werfen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m39s
In 0bfeba2 sind via git add -A versehentlich zwei lokale Artefakte
mitgewandert: ci-log.txt (lokaler CI-Log-Dump) und
.claude/scheduled_tasks.lock (Claude-Code Session-Lock). Beide gehören
nicht ins Repo. Per git rm --cached entfernt und in .gitignore
geblacklistet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:42:53 +02:00
hsiegeln
0bfeba2c0a feat(home): Pagination-Tiefe per Snapshot, scroll-restore deep-scroll-fest
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Bei tiefer Endless-Scroll (z. B. 60 nachgeladene Items) versagte der
generische scroll-restore: nach dem Back-Mount lud onMount() nur die
initialen 10 Items via loadAllMore(), das Dokument blieb ~1000px hoch
und der rAF-Poll konnte die gespeicherte scrollY (z. B. 4000) nie
erreichen — Best-Effort scrollTo clampte auf die erreichbare Hoehe.

Fix per SvelteKit-Snapshot, derselbe Mechanismus den die Page bereits
fuer SearchStore nutzt: Capture nimmt zusaetzlich allRecipes.length,
allSort und allExhausted mit. Restore setzt sort sofort und parkt die
Tiefe in pendingPagination. onMount sieht das Pending und ruft statt
loadAllMore() ein einmaliges rehydrateAll(sort, count, exhausted) —
ein Fetch mit limit=count rehydriert die ganze Liste atomar. Danach
hat das Dokument die Originalhoehe und der Layout-Restore-Poll laesst
die scrollY genau dort landen, wo der User vorher war.

API-Cap (src/routes/api/recipes/all/+server.ts) von 50 auf 200
angehoben — Recipe-Metadaten sind klein (~200 B/Stueck), 200er-
Response ~40 KB. Cap deckt realistische Scroll-Tiefen.

Reload (Cmd-R) behaelt das alte Verhalten: ohne Snapshot greift der
sort-aus-localStorage-Pfad, lade-Sequenz startet wieder bei 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:42:16 +02:00
hsiegeln
f3e2cebfb4 fix(nav): scroll-restore key auf nav.from.url, nicht location
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m51s
Live-Test auf kochwas-dev offenbarte: bei Browser-Back hat der Browser
die History bereits gepoppt, bevor SvelteKit beforeNavigate feuert —
location.pathname war damit schon die Ziel-URL. recordScroll() schrieb
also den 0-Wert der Recipe-Page in den Slot der Home-Page und wischte
den eigentlich gemerkten Wert (z. B. 500) raus. Restore las dann 0,
fiel unter MIN_RESTORE_Y und tat nichts.

Fix: recordScroll(nav.from?.url) und restoreScroll(type, nav.to?.url).
Helper bekommen die URL explizit reingereicht — keine Abhängigkeit
mehr von location und damit kein Race mit der Browser-History.

Tests: zusätzliche Regression "does not overwrite a stored URL when
called with a different from-url" plus Skip-Pfade fuer null URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:28:20 +02:00
hsiegeln
442076a278 fix(nav): Scroll-Position bei Browser-Back robust wiederherstellen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m44s
Pages, die ihre Daten in onMount per fetch laden (Home, Wunschliste,
Einkaufsliste), waren bei popstate-Navigation kaputt: SvelteKit ruft
scrollTo() synchron nach Mount, aber die Listen sind dann noch leer
und das Dokument zu kurz — der Browser clamped auf 0.

Neuer Helper src/lib/client/scroll-restore.ts merkt scrollY pro URL in
sessionStorage (beforeNavigate) und stellt sie bei popstate per rAF-
Polling wieder her, sobald document.scrollHeight gross genug ist
(Hard-Budget 1.5s, danach best-effort scrollTo).

Layout ruft die zwei Helper im beforeNavigate / afterNavigate. Pages
mit SSR-Daten (z. B. /recipes) bleiben unbeeinflusst — dort matcht
unser Wert SvelteKits eigenen scrollTo bereits beim ersten Frame.

Tests: 7 neue Unit-Tests in tests/unit/scroll-restore.test.ts decken
Recording, Pro-URL-Trennung, Skip fuer Forward-Nav, sofortiges und
verzoegertes Restore ab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:18:00 +02:00
hsiegeln
4afc597689 fix(nav): Header-Back-Pfeil als echtes history.back()
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m19s
Der Zurück-Pfeil im Header war fest auf "/" verdrahtet und navigierte
forward, nicht backward. Damit ging die Scroll-Position der Origin-Seite
verloren und z. B. Wunschliste -> Rezept -> Zurück landete auf der
Startseite statt zurück auf der Wunschliste.

Jetzt: history.back() (mit goto('/') als Fallback bei leerer History).
SvelteKits eingebaute Scroll-Restoration greift dadurch wieder, und der
Pfeil tut was er optisch verspricht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:09:45 +02:00
hsiegeln
42b1aed023 fix(shopping-e2e): beforeEach-Cleanup + checked-Count statt first-Row
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 32s
2026-04-22 08:57:17 +02:00
hsiegeln
a15390f4b8 fix(shopping): requireOnline-Guards + 2-space indent
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m14s
2026-04-21 23:59:14 +02:00
hsiegeln
52bb83cbd5 fix(shopping-e2e): exact match fuer Leeren-Confirm-Button
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 36s
2026-04-21 23:52:49 +02:00
hsiegeln
4e902b1d98 test(shopping): E2E-Spec + clearShoppingCart-Fixture
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 33s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:50:05 +02:00
hsiegeln
0346a699b9 feat(shopping): Footer-Actions (Erledigte entfernen, Liste leeren)
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:47:30 +02:00
hsiegeln
f4eac4d9c3 feat(shopping): Rezept-Chips mit Portions-Stepper
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:45:32 +02:00
hsiegeln
3c30d1f35a feat(shopping): Zutaten-Rows mit Abhaken
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m17s
2026-04-21 23:43:00 +02:00
hsiegeln
943a645095 feat(shopping): Einkaufslisten-Seite mit Empty-State
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:40:52 +02:00
hsiegeln
7fa1079125 refactor(wishlist): horizontale Actions + Einkaufswagen-Button
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m15s
2026-04-21 23:38:26 +02:00
hsiegeln
0e6d2c93a6 feat(shopping): Header-Badge mit Einkaufswagen-Icon
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
2026-04-21 23:34:57 +02:00
hsiegeln
1bd5dd106f feat(shopping): ShoppingCartStore (Client)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
Svelte-5-Runes-Store mit uncheckedCount, recipeIds und loaded.
refresh() holt Snapshot via GET /api/shopping-list, addRecipe/
removeRecipe posten bzw. loeschen und refreshen anschliessend.
Bei Netzwerkfehler bleibt der letzte bekannte State erhalten.
2026-04-21 23:31:29 +02:00
hsiegeln
dc15cf04a9 feat(shopping): Service-Worker network-only fuer /api/shopping-list/*
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m25s
Einkaufsliste-Endpunkte duerfen vom SW nie gecached werden — Liste
ist zustaendig fuer Check-States und muss immer live vom Server
gelesen werden. Test + resolveStrategy-Erweiterung analog zu den
anderen online-only-Endpunkten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:28:17 +02:00
hsiegeln
e53cdc96fe feat(shopping): DELETE /api/shopping-list/checked (Erledigte entfernen)
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:26:32 +02:00
hsiegeln
a500a5623e feat(shopping): POST/DELETE /api/shopping-list/check
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:25:05 +02:00
hsiegeln
2750c298e9 feat(shopping): PATCH/DELETE /api/shopping-list/recipe/[id]
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:23:21 +02:00
hsiegeln
7baf60f422 feat(shopping): POST /api/shopping-list/recipe
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:21:35 +02:00
hsiegeln
e176b8c3f2 style(shopping): GET/DELETE endpoint 2-space indent
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:20:03 +02:00
hsiegeln
8570d41f53 feat(shopping): GET /api/shopping-list + DELETE (Liste leeren)
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:18:24 +02:00
hsiegeln
76864a6034 feat(shopping): formatQuantity-Utility
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:16:23 +02:00
hsiegeln
2c61d82935 feat(shopping): clearCart
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
2026-04-21 23:13:58 +02:00
hsiegeln
974227590f feat(shopping): clearCheckedItems + Orphan-Cleanup
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m13s
2026-04-21 23:11:25 +02:00
hsiegeln
1889b0dea0 feat(shopping): toggleCheck (idempotent)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m20s
Idempotentes Setzen/Loeschen von shopping_cart_check-Eintraegen
ueber (name_key, unit_key). Check ueberlebt Recipe-Removals,
solange ein anderes Rezept weiterhin zur Zeile beitraegt —
verifiziert durch 3 neue Integrationstests (17 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:08:20 +02:00
hsiegeln
494b672e8d fix(shopping): NULLIF-Guard gegen servings_default=0 in Aggregation
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 23:06:19 +02:00
hsiegeln
c31a9c6110 feat(shopping): listShoppingList mit Aggregation + Skalierung
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
2026-04-21 23:02:05 +02:00
hsiegeln
85bf197084 feat(shopping): setCartServings mit Positiv-Validation
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m15s
2026-04-21 22:59:12 +02:00
hsiegeln
83fe95ac76 feat(shopping): removeRecipeFromCart
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m27s
2026-04-21 22:56:26 +02:00
hsiegeln
95ba14ad6f refactor(shopping): DEFAULT_SERVINGS-Konstante + Kommentare
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
2026-04-21 22:54:54 +02:00
hsiegeln
8ceb5e95d7 feat(shopping): addRecipeToCart (idempotent via ON CONFLICT)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m20s
2026-04-21 22:50:58 +02:00
hsiegeln
7dab267033 feat(shopping): Repository-Skeleton mit Types
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
2026-04-21 22:47:21 +02:00
hsiegeln
45223df86d chore(db): Migration 013 fuer Einkaufsliste-Tabellen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m20s
2026-04-21 22:43:50 +02:00
hsiegeln
fd5d759336 docs(plan): Implementierungs-Plan fuer Einkaufsliste
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 31s
24 Tasks, TDD, bite-sized. Reihenfolge: Migration -> Repository -> Quantity-Formatter
-> API -> Service-Worker -> Client-Store -> Header-Badge -> Wunschlisten-Relayout
-> Shopping-List-Seite -> E2E.

E2E-Tests laufen erst nach Deploy gegen kochwas-dev.siegeln.net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:37:04 +02:00
hsiegeln
956357d5ca docs(spec): Einkaufsliste-Design
Neue Spec fuer das Einkaufslisten-Feature:
- Globale (haushaltsweite) Einkaufsliste, aus Rezepten der Wunschliste gefuellt
- Portionen zentral auf der Listen-Seite skalierbar
- Flache Aggregation via (LOWER(TRIM(name)), LOWER(TRIM(unit)))
- Abhaken persistiert, Cleanup manuell
- Header-Badge zaehlt nicht-abgehakte Zeilen
- Relayout der Wunschlisten-Karte: Action-Icons horizontal oben, Quell-Domain raus
- Kein Fuzzy-Matching, keine manuellen Eintraege (YAGNI fuer v1)

E2E-Tests erst nach Deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:25:46 +02:00
hsiegeln
d9490c8073 refactor(search): local search ignores domain filter
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m11s
Der Domain-Filter im Header-Dropdown wirkt ab jetzt ausschliesslich auf
die Web-Suche (SearXNG). Die Suche in gespeicherten Rezepten liefert
immer alle Treffer, unabhaengig von der Quelldomain -- wer ein Rezept
gespeichert hat, will es finden, selbst wenn er die Domain aus dem
Filter ausgeschlossen hat.

- SearchStore: filterParam -> webFilterParam, nur noch an Web-Calls
- /api/recipes/search: domains-Query-Param wird nicht mehr gelesen
- searchLocal(): domains-Parameter + SQL-Branch entfernt
- Tests entsprechend angepasst

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:59:48 +02:00
hsiegeln
0373dc32da feat(ai): Deutsch als starker Prior im OCR-Prompt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
Neue SPRACHE-Sektion weist Gemini explizit darauf hin, dass die
Texte ausschliesslich deutsch sind -- Umlaute, deutsche Zutaten,
deutsche Masseinheiten als Prior fuer die Zeichen-Rekonstruktion.
Soll die "Kontext-Detektiv"-Logik bei handgeschriebenen oder
verblassten Rezepten verbessern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:28:38 +02:00
hsiegeln
272a07777e feat(ai): OCR-Experten-Framing + expliziter User-Prompt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m18s
Auf Gemini-Empfehlung: System-Instruction als OCR-Experte fuer
kulinarische Dokumente, mit "Kontext-Detektiv"-Regel fuer schwer
lesbare Zeichen, "[?]" fuer Unleserliches und strikter "keine
Halluzination"-Regel.

User-Prompt wird jetzt als eigene text-part bei jedem Call
mitgeschickt (Bild + User-Prompt + bei Retry die Korrektur-Note).

Inline-Schema aus dem Prompt entfernt, da es mit unserem
responseSchema konfligierte (servings vs servings_default+unit,
times-nested vs flat, instructions vs steps, kein note-Feld) --
das kann die beobachteten AI_FAILED-Schema-Validation-Fehler
beguenstigt haben. Struktur wird jetzt ausschliesslich ueber
responseSchema enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:26:18 +02:00
hsiegeln
efdcace892 feat(ai): reichhaltigeres Logging fuer AI_FAILED-Diagnose
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m15s
Der bisherige Log "[extract-from-photo] AI_FAILED after 43165ms,
385807 bytes" verriet nicht, ob es JSON-Parse, Schema-Validierung
oder ein SDK-Fehler war. Endpoint haengt jetzt e.message an;
gemini-client loggt den First-Attempt-Fehler vor dem Retry und
packt bei AI_FAILED beide Messages in den finalen Error.

Keine Prompt-/Response-Inhalte werden geloggt -- nur unsere eigenen
GeminiError-Messages (Zod-Pfade, "non-JSON output", SDK-toString).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:08:10 +02:00
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
80 changed files with 12791 additions and 560 deletions

View File

@@ -15,3 +15,9 @@ BRAVE_API_KEY=
# SearXNG-Secret: beliebig lange Zufallskette. Für Prod mit # SearXNG-Secret: beliebig lange Zufallskette. Für Prod mit
# `openssl rand -hex 32` generieren und in der Pi-.env ablegen. # `openssl rand -hex 32` generieren und in der Pi-.env ablegen.
SEARXNG_SECRET=dev-secret-change-me 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

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ test-results/
playwright-report/ playwright-report/
playwright-report-remote/ playwright-report-remote/
.playwright-mcp/ .playwright-mcp/
.claude/
ci-log.txt

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`. | | **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. | | **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. | | **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 ## Dateien, die man typischerweise anfasst
@@ -26,6 +28,8 @@ 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/+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/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/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/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/search/searxng.ts` — Web-Suche + Thumbnail-Enrichment + SQLite-Cache
- `src/lib/server/recipes/importer.ts` — JSON-LD → Recipe, orchestriert Bild-Download - `src/lib/server/recipes/importer.ts` — JSON-LD → Recipe, orchestriert Bild-Download

View File

@@ -3,17 +3,44 @@
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
# Alpine needs build tools for better-sqlite3 native module # Alpine needs build tools for better-sqlite3 native module.
RUN apk add --no-cache python3 make g++ libc6-compat # 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 ./ 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 . . COPY . .
RUN npm run build RUN npm run build
# Remove dev dependencies for the runtime image # Fresh-Install fuer den Runtime-Stage: nur Produktions-Deps, gleiche Strategie.
RUN npm prune --omit=dev 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 FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app

View File

@@ -11,6 +11,16 @@ services:
- IMAGE_DIR=/data/images - IMAGE_DIR=/data/images
- SEARXNG_URL=http://searxng:8080 - SEARXNG_URL=http://searxng:8080
- NODE_ENV=production - 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: depends_on:
- searxng - searxng
restart: unless-stopped restart: unless-stopped

View File

@@ -58,6 +58,16 @@ 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` 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]` 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 ### 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. 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.
@@ -120,11 +130,11 @@ Bei Schema-Änderung:
- **Pre-Cache** (alle Rezepte + Bilder beim Initial-Sync), über paginierten Fetch von `/api/recipes/all`. - **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). - **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. - **Message-Protokoll** (`sync-start`, `sync-progress`, `sync-done`, `sync-error`) zwischen SW und Client.
Reine Logik-Einheiten (testbar, Unit-Tests in `tests/unit/`): 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}` - `src/lib/sw/diff-manifest.ts``diffManifest(current, cached)``{toAdd, toRemove}`
Client-Stores (SSR-safe via typeof-Guards): 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): Caches im Browser (siehe DevTools → Application → Cache Storage):
- `kochwas-shell-<version>` — App-Shell (JS/CSS/Static-Icons), cache-first - `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-images-v1` — Bilder (cache-first)
- `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`) - `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`)
@@ -187,3 +187,26 @@ Wichtige Config-Eigenschaften:
- Fixtures unter `tests/e2e/remote/fixtures/`: `profile.ts` (Profile-Auswahl via localStorage vor Seitenladen), `api-cleanup.ts` (idempotente DELETE-Helfer für afterEach). - 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. **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).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,887 @@
# Einkaufsliste Mengen-Konsolidierung 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:** Verschiedene Unit-Varianten derselben Zutat (500 g + 1 kg Kartoffeln) in der Einkaufsliste zu einer Zeile konsolidieren (→ 1,5 kg). Scope: g↔kg, ml↔l.
**Architecture:** Zwei reine TS-Utilities (`unitFamily`, `consolidate`) kapseln die Logik. `listShoppingList()` lässt SQL weiterhin pro (name, unit) aggregieren, bündelt die Zeilen dann in TS pro `(name, unitFamily)` und konsolidiert. Migration 015 macht `shopping_cart_check.unit_key` zum Family-Key, damit Abhaks nicht verloren gehen wenn Display-Unit zwischen g und kg wechselt. `formatQuantity` wechselt app-weit auf `toLocaleString('de-DE')` (Komma als Dezimaltrennzeichen).
**Tech Stack:** SvelteKit, better-sqlite3, Vitest. Keine neuen Deps.
---
## File Structure
**Create:**
- `src/lib/server/shopping/unit-consolidation.ts``unitFamily()` + `consolidate()`
- `src/lib/server/db/migrations/015_shopping_check_family.sql` — Family-Key-Migration
- `tests/unit/unit-consolidation.test.ts` — Unit-Tests
**Modify:**
- `src/lib/quantity-format.ts``toLocaleString('de-DE', …)` statt Punkt
- `tests/unit/quantity-format.test.ts` — Erwartungen auf Komma anpassen
- `src/lib/server/shopping/repository.ts``listShoppingList`, `toggleCheck`, `clearCheckedItems` auf Family-Key umstellen
- `tests/integration/shopping-repository.test.ts` — neue Describe-Blöcke für Konsolidierung
---
### Task 1: Unit-Family-Utility
**Files:**
- Create: `src/lib/server/unit-consolidation.ts`
- Test: `tests/unit/unit-consolidation.test.ts`
Hinweis: Datei bewusst in `src/lib/server/` (nicht in `shopping/`), weil `unitFamily` auch vom Migration-Code referenziert wird — eine Ebene höher ist intuitiver.
- [ ] **Step 1: Write failing tests for `unitFamily`**
Create `tests/unit/unit-consolidation.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { unitFamily } from '../../src/lib/server/unit-consolidation';
describe('unitFamily', () => {
it('maps g and kg to weight', () => {
expect(unitFamily('g')).toBe('weight');
expect(unitFamily('kg')).toBe('weight');
});
it('maps ml and l to volume', () => {
expect(unitFamily('ml')).toBe('volume');
expect(unitFamily('l')).toBe('volume');
});
it('lowercases and trims unknown units', () => {
expect(unitFamily(' Bund ')).toBe('bund');
expect(unitFamily('TL')).toBe('tl');
expect(unitFamily('Stück')).toBe('stück');
});
it('is case-insensitive for weight/volume', () => {
expect(unitFamily('Kg')).toBe('weight');
expect(unitFamily('ML')).toBe('volume');
});
it('returns empty string for null/undefined/empty', () => {
expect(unitFamily(null)).toBe('');
expect(unitFamily(undefined)).toBe('');
expect(unitFamily('')).toBe('');
expect(unitFamily(' ')).toBe('');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/unit/unit-consolidation.test.ts`
Expected: All fail with "Cannot find module …/unit-consolidation".
- [ ] **Step 3: Implement `unitFamily`**
Create `src/lib/server/unit-consolidation.ts`:
```typescript
export type UnitFamily = 'weight' | 'volume' | string;
const WEIGHT_UNITS = new Set(['g', 'kg']);
const VOLUME_UNITS = new Set(['ml', 'l']);
export function unitFamily(unit: string | null | undefined): UnitFamily {
const u = (unit ?? '').trim().toLowerCase();
if (WEIGHT_UNITS.has(u)) return 'weight';
if (VOLUME_UNITS.has(u)) return 'volume';
return u;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/unit/unit-consolidation.test.ts`
Expected: 5 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/lib/server/unit-consolidation.ts tests/unit/unit-consolidation.test.ts
git commit -m "feat(shopping): unitFamily-Utility fuer Konsolidierung"
```
---
### Task 2: Consolidate-Funktion
**Files:**
- Modify: `src/lib/server/unit-consolidation.ts`
- Modify: `tests/unit/unit-consolidation.test.ts`
- [ ] **Step 1: Append failing tests for `consolidate` to the existing test file**
Append to `tests/unit/unit-consolidation.test.ts` (after the `unitFamily` describe):
```typescript
import { consolidate } from '../../src/lib/server/unit-consolidation';
describe('consolidate', () => {
it('kombiniert 500 g + 1 kg zu 1,5 kg', () => {
const out = consolidate([
{ quantity: 500, unit: 'g' },
{ quantity: 1, unit: 'kg' }
]);
expect(out).toEqual({ quantity: 1.5, unit: 'kg' });
});
it('bleibt bei g wenn Summe < 1 kg', () => {
const out = consolidate([
{ quantity: 200, unit: 'g' },
{ quantity: 300, unit: 'g' }
]);
expect(out).toEqual({ quantity: 500, unit: 'g' });
});
it('kombiniert ml + l analog (400 ml + 0,5 l → 900 ml)', () => {
const out = consolidate([
{ quantity: 400, unit: 'ml' },
{ quantity: 0.5, unit: 'l' }
]);
expect(out).toEqual({ quantity: 900, unit: 'ml' });
});
it('promoted zu l ab 1000 ml (0,5 l + 0,8 l → 1,3 l)', () => {
const out = consolidate([
{ quantity: 0.5, unit: 'l' },
{ quantity: 0.8, unit: 'l' }
]);
expect(out).toEqual({ quantity: 1.3, unit: 'l' });
});
it('summiert gleiche nicht-family-units (2 Bund + 1 Bund → 3 Bund)', () => {
const out = consolidate([
{ quantity: 2, unit: 'Bund' },
{ quantity: 1, unit: 'Bund' }
]);
expect(out).toEqual({ quantity: 3, unit: 'Bund' });
});
it('behandelt quantity=null als 0', () => {
const out = consolidate([
{ quantity: null, unit: 'TL' },
{ quantity: 1, unit: 'TL' }
]);
expect(out).toEqual({ quantity: 1, unit: 'TL' });
});
it('gibt null zurueck wenn alle quantities null sind', () => {
const out = consolidate([
{ quantity: null, unit: 'Prise' },
{ quantity: null, unit: 'Prise' }
]);
expect(out).toEqual({ quantity: null, unit: 'Prise' });
});
it('rundet Float-Artefakte auf 2 Dezimalen (0,1 + 0,2 kg → 0,3 kg)', () => {
const out = consolidate([
{ quantity: 0.1, unit: 'kg' },
{ quantity: 0.2, unit: 'kg' }
]);
// 0.1 + 0.2 in kg = 0.3 kg, in g = 300 → promoted? 300 < 1000 → 300 g
expect(out).toEqual({ quantity: 300, unit: 'g' });
});
it('nimmt unit vom ersten Eintrag bei unbekannter family', () => {
const out = consolidate([{ quantity: 5, unit: 'Stück' }]);
expect(out).toEqual({ quantity: 5, unit: 'Stück' });
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/unit/unit-consolidation.test.ts`
Expected: Fail with "consolidate is not a function" or similar (9 new tests fail).
- [ ] **Step 3: Implement `consolidate`**
Append to `src/lib/server/unit-consolidation.ts`:
```typescript
export interface QuantityInUnit {
quantity: number | null;
unit: string | null;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* Konsolidiert mehrere {quantity, unit}-Eintraege derselben Unit-Family
* zu einer gemeinsamen Menge + Display-Unit.
*
* - Gewicht (g, kg): summiert in g, promoted bei >=1000 g auf kg.
* - Volumen (ml, l): summiert in ml, promoted bei >=1000 ml auf l.
* - Andere: summiert quantity ohne Umrechnung, Display-Unit vom ersten
* Eintrag.
*
* quantity=null wird als 0 behandelt. Wenn ALLE quantities null sind,
* ist die Gesamtmenge ebenfalls null.
*/
export function consolidate(rows: QuantityInUnit[]): QuantityInUnit {
if (rows.length === 0) return { quantity: null, unit: null };
const family = unitFamily(rows[0].unit);
const firstUnit = rows[0].unit;
const allNull = rows.every((r) => r.quantity === null);
if (family === 'weight') {
if (allNull) return { quantity: null, unit: firstUnit };
const grams = rows.reduce((sum, r) => {
const q = r.quantity ?? 0;
return sum + (unitFamily(r.unit) === 'weight' && r.unit?.toLowerCase().trim() === 'kg' ? q * 1000 : q);
}, 0);
if (grams >= 1000) return { quantity: round2(grams / 1000), unit: 'kg' };
return { quantity: round2(grams), unit: 'g' };
}
if (family === 'volume') {
if (allNull) return { quantity: null, unit: firstUnit };
const ml = rows.reduce((sum, r) => {
const q = r.quantity ?? 0;
return sum + (r.unit?.toLowerCase().trim() === 'l' ? q * 1000 : q);
}, 0);
if (ml >= 1000) return { quantity: round2(ml / 1000), unit: 'l' };
return { quantity: round2(ml), unit: 'ml' };
}
// Non-family: summiere quantity direkt
if (allNull) return { quantity: null, unit: firstUnit };
const sum = rows.reduce((acc, r) => acc + (r.quantity ?? 0), 0);
return { quantity: round2(sum), unit: firstUnit };
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/unit/unit-consolidation.test.ts`
Expected: 14 tests pass (5 from Task 1 + 9 new).
- [ ] **Step 5: Commit**
```bash
git add src/lib/server/unit-consolidation.ts tests/unit/unit-consolidation.test.ts
git commit -m "feat(shopping): consolidate() fuer g/kg + ml/l Summierung"
```
---
### Task 3: formatQuantity auf deutsches Locale
**Files:**
- Modify: `src/lib/quantity-format.ts`
- Modify: `tests/unit/quantity-format.test.ts`
- [ ] **Step 1: Update tests to expect comma decimal**
Open `tests/unit/quantity-format.test.ts`. Jede Erwartung mit Dezimalpunkt auf Komma ändern, z. B.:
```typescript
// vorher: expect(formatQuantity(0.333)).toBe('0.33');
// nachher:
expect(formatQuantity(0.333)).toBe('0,33');
```
Betroffene Assertions (aus dem bestehenden Test-File):
- `formatQuantity(0.333)``'0,33'`
- `formatQuantity(0.5)``'0,5'`
- `formatQuantity(1.25)``'1,25'`
Ganze Zahlen (`formatQuantity(3)``'3'`) und null (`''`) bleiben gleich.
- [ ] **Step 2: Run tests to verify they fail with current implementation**
Run: `npm test -- tests/unit/quantity-format.test.ts`
Expected: Tests with decimal values fail (`'0.33'` received, `'0,33'` expected).
- [ ] **Step 3: Rewrite `formatQuantity` mit toLocaleString**
Replace contents of `src/lib/quantity-format.ts`:
```typescript
export function formatQuantity(q: number | null): string {
if (q === null || q === undefined) return '';
return q.toLocaleString('de-DE', {
maximumFractionDigits: 2,
useGrouping: false
});
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/unit/quantity-format.test.ts`
Expected: Alle 5 Tests grün.
- [ ] **Step 5: Run full suite to catch app-wide regressions**
Run: `npm test`
Expected: Alle Tests grün. Falls andere Tests (z. B. Rezept-Detail-Rendering) Erwartungen auf `'.'` haben und fehlschlagen, Assertions dort auf Komma anpassen und in denselben Commit nehmen.
- [ ] **Step 6: Commit**
```bash
git add src/lib/quantity-format.ts tests/unit/quantity-format.test.ts
git commit -m "feat(format): formatQuantity app-weit auf de-DE Komma-Dezimal"
```
---
### Task 4: Migration 015 — Check-Keys auf Family
**Files:**
- Create: `src/lib/server/db/migrations/015_shopping_check_family.sql`
Hinweis: Migrations werden via `import.meta.glob('./migrations/*.sql', {eager, query:'?raw'})` gebundelt (siehe CLAUDE.md) — kein Dockerfile-Copy nötig.
- [ ] **Step 1: Write the migration**
Create `src/lib/server/db/migrations/015_shopping_check_family.sql`:
```sql
-- Konsolidierung: unit_key in shopping_cart_check wird zum Family-Key, damit
-- Abhaks stabil bleiben wenn Display-Unit zwischen g und kg wechselt.
-- g/kg → 'weight', ml/l → 'volume', Rest bleibt unveraendert.
UPDATE shopping_cart_check SET unit_key = 'weight' WHERE LOWER(TRIM(unit_key)) IN ('g', 'kg');
UPDATE shopping_cart_check SET unit_key = 'volume' WHERE LOWER(TRIM(unit_key)) IN ('ml', 'l');
-- Nach Relabeling koennen Duplikate entstehen (zwei Zeilen mit 'weight' pro
-- name_key). Juengsten Eintrag behalten.
DELETE FROM shopping_cart_check
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM shopping_cart_check
GROUP BY name_key, unit_key
);
```
- [ ] **Step 2: Verify migration runs (smoke test via any integration test)**
Run: `npm test -- tests/integration/shopping-repository.test.ts`
Expected: Alle bestehenden Tests grün (Migration läuft beim `openInMemoryForTest()`, bricht nichts weil Tabelle beim ersten Lauf leer ist).
- [ ] **Step 3: Commit**
```bash
git add src/lib/server/db/migrations/015_shopping_check_family.sql
git commit -m "feat(shopping): Migration 015 — Check-Keys auf Unit-Family"
```
---
### Task 5: listShoppingList mit Family-Konsolidierung
**Files:**
- Modify: `src/lib/server/shopping/repository.ts:70-107`
- Modify: `tests/integration/shopping-repository.test.ts`
- [ ] **Step 1: Write failing integration test**
Append to `tests/integration/shopping-repository.test.ts` (z. B. nach dem vorhandenen `addRecipeToCart`-Block, ein eigener Describe-Block):
```typescript
describe('listShoppingList — Konsolidierung ueber Einheiten', () => {
it('fasst 500 g + 1 kg Kartoffeln zu 1,5 kg zusammen', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'Kartoffelsuppe',
servings_default: 4,
ingredients: [
{ position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: null }
]
})
);
const b = insertRecipe(
db,
recipe({
title: 'Kartoffelpuffer',
servings_default: 4,
ingredients: [
{ position: 1, name: 'Kartoffeln', quantity: 1, unit: 'kg', note: null, raw_text: null }
]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
const snap = listShoppingList(db);
const kartoffeln = snap.rows.filter((r) => r.display_name.toLowerCase() === 'kartoffeln');
expect(kartoffeln).toHaveLength(1);
expect(kartoffeln[0].total_quantity).toBe(1.5);
expect(kartoffeln[0].display_unit).toBe('kg');
});
it('kombiniert ml + l korrekt (400 ml + 0,5 l → 900 ml)', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [{ position: 1, name: 'Milch', quantity: 400, unit: 'ml', note: null, raw_text: null }]
})
);
const b = insertRecipe(
db,
recipe({
title: 'R2',
servings_default: 4,
ingredients: [{ position: 1, name: 'Milch', quantity: 0.5, unit: 'l', note: null, raw_text: null }]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
const milch = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'milch');
expect(milch).toHaveLength(1);
expect(milch[0].total_quantity).toBe(900);
expect(milch[0].display_unit).toBe('ml');
});
it('laesst inkompatible Families getrennt (5 Stueck Eier + 500 g Eier = 2 Zeilen)', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [{ position: 1, name: 'Eier', quantity: 5, unit: 'Stück', note: null, raw_text: null }]
})
);
const b = insertRecipe(
db,
recipe({
title: 'R2',
servings_default: 4,
ingredients: [{ position: 1, name: 'Eier', quantity: 500, unit: 'g', note: null, raw_text: null }]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
const eier = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'eier');
expect(eier).toHaveLength(2);
});
it('summiert gleiche Unit-Family ohne Konversion (2 Bund + 1 Bund → 3 Bund)', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [{ position: 1, name: 'Petersilie', quantity: 2, unit: 'Bund', note: null, raw_text: null }]
})
);
const b = insertRecipe(
db,
recipe({
title: 'R2',
servings_default: 4,
ingredients: [{ position: 1, name: 'Petersilie', quantity: 1, unit: 'Bund', note: null, raw_text: null }]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
const petersilie = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'petersilie');
expect(petersilie).toHaveLength(1);
expect(petersilie[0].total_quantity).toBe(3);
expect(petersilie[0].display_unit?.toLowerCase()).toBe('bund');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/integration/shopping-repository.test.ts`
Expected: 4 neue Tests fail (Konsolidierung existiert noch nicht).
- [ ] **Step 3: Rewrite `listShoppingList` to use TS-side consolidation**
Replace the `listShoppingList` body in `src/lib/server/shopping/repository.ts` (Zeilen 70-107):
```typescript
import { consolidate, unitFamily } from '../unit-consolidation';
// (oben im File unter den bestehenden Imports einfuegen)
export function listShoppingList(
db: Database.Database
): ShoppingListSnapshot {
const recipes = db
.prepare(
`SELECT cr.recipe_id, r.title, r.image_path, cr.servings,
COALESCE(r.servings_default, cr.servings) AS servings_default
FROM shopping_cart_recipe cr
JOIN recipe r ON r.id = cr.recipe_id
ORDER BY cr.added_at ASC`
)
.all() as ShoppingCartRecipe[];
// SQL aggregiert weiterhin pro (name, raw-unit). Die family-Gruppierung
// + Konsolidierung macht TypeScript, damit SQL lesbar bleibt und die
// Logik Unit-testbar ist.
type RawRow = {
name_key: string;
unit_key: string;
display_name: string;
display_unit: string | null;
total_quantity: number | null;
from_recipes: string;
};
const raw = db
.prepare(
`SELECT
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key,
MIN(i.name) AS display_name,
MIN(i.unit) AS display_unit,
SUM(i.quantity * cr.servings * 1.0 / NULLIF(COALESCE(r.servings_default, cr.servings), 0)) AS total_quantity,
GROUP_CONCAT(DISTINCT r.title) AS from_recipes
FROM shopping_cart_recipe cr
JOIN recipe r ON r.id = cr.recipe_id
JOIN ingredient i ON i.recipe_id = r.id
GROUP BY name_key, unit_key`
)
.all() as RawRow[];
// Check-Keys einmalig vorladen
const checkedSet = new Set(
(
db
.prepare('SELECT name_key, unit_key FROM shopping_cart_check')
.all() as { name_key: string; unit_key: string }[]
).map((c) => `${c.name_key}|${c.unit_key}`)
);
// Gruppieren nach (name_key, unitFamily(unit_key))
const grouped = new Map<string, RawRow[]>();
for (const r of raw) {
const familyKey = unitFamily(r.unit_key);
const key = `${r.name_key}|${familyKey}`;
const arr = grouped.get(key) ?? [];
arr.push(r);
grouped.set(key, arr);
}
const rows: ShoppingListRow[] = [];
for (const [key, members] of grouped) {
const [nameKey, familyKey] = key.split('|');
const consolidated = consolidate(
members.map((m) => ({ quantity: m.total_quantity, unit: m.display_unit }))
);
// display_name: ersten nehmen (alle Member haben dasselbe name_key)
const displayName = members[0].display_name;
// from_recipes: alle unique Titel aus den Members kombinieren
const allRecipes = new Set<string>();
for (const m of members) {
for (const t of m.from_recipes.split(',')) allRecipes.add(t);
}
rows.push({
name_key: nameKey,
unit_key: familyKey, // wichtig: family-key, matched mit checked-Lookup
display_name: displayName,
display_unit: consolidated.unit,
total_quantity: consolidated.quantity,
from_recipes: [...allRecipes].join(','),
checked: checkedSet.has(`${nameKey}|${familyKey}`) ? 1 : 0
});
}
// Sort wie bisher: erst unchecked, dann alphabetisch by display_name
rows.sort((a, b) => {
if (a.checked !== b.checked) return a.checked - b.checked;
return a.display_name.localeCompare(b.display_name, 'de', { sensitivity: 'base' });
});
const uncheckedCount = rows.reduce((n, r) => n + (r.checked ? 0 : 1), 0);
return { recipes, rows, uncheckedCount };
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/integration/shopping-repository.test.ts`
Expected: Alle Tests grün (bestehende + 4 neue).
- [ ] **Step 5: Commit**
```bash
git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): listShoppingList konsolidiert g/kg + ml/l"
```
---
### Task 6: toggleCheck + clearCheckedItems auf Family-Key
**Files:**
- Modify: `src/lib/server/shopping/repository.ts:109-188`
- Modify: `tests/integration/shopping-repository.test.ts`
- [ ] **Step 1: Write failing integration tests**
Append to `tests/integration/shopping-repository.test.ts`:
```typescript
describe('toggleCheck — stabil ueber Unit-Family', () => {
it('haekchen bleibt erhalten wenn Gesamtmenge von kg auf g faellt', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [{ position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: null }]
})
);
const b = insertRecipe(
db,
recipe({
title: 'R2',
servings_default: 4,
ingredients: [{ position: 1, name: 'Kartoffeln', quantity: 1, unit: 'kg', note: null, raw_text: null }]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
// Abhaken der konsolidierten 1,5-kg-Zeile via family-key
const before = listShoppingList(db).rows[0];
toggleCheck(db, before.name_key, before.unit_key, true);
expect(listShoppingList(db).rows[0].checked).toBe(1);
// Ein Rezept rausnehmen → nur noch 500 g, display wechselt auf g
removeRecipeFromCart(db, b);
const after = listShoppingList(db).rows[0];
expect(after.display_unit).toBe('g');
expect(after.total_quantity).toBe(500);
// Haekchen bleibt: unit_key ist weiterhin 'weight'
expect(after.checked).toBe(1);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/integration/shopping-repository.test.ts`
Expected: Der neue Test failt, weil `toggleCheck` noch mit `unit_key` als raw unit arbeitet — der Check wird mit `'weight'` geschrieben, ABER das Schreiben selbst könnte fälschlich doch durchgehen (toggleCheck macht ja nur INSERT mit dem gegebenen Key). Der Failure entsteht beim zweiten `listShoppingList()`: der Lookup-Key matched noch nicht mit dem gespeicherten Check.
Tatsächlich: Mit Task 5 schreibt `toggleCheck(db, name, 'weight', true)` einen Eintrag `(kartoffeln, 'weight')` in `shopping_cart_check`. `listShoppingList` liest den Check mit dem Family-Key — also passt. Der Test müsste grün sein _wenn_ toggleCheck unverändert funktioniert.
Hmm — let me re-check. `toggleCheck(db, nameKey, unitKey, checked)` nimmt einfach den String, den der Caller übergibt, und speichert. Das ist agnostisch. Also wenn die UI `row.unit_key` durchreicht (was ja jetzt 'weight' ist), funktioniert das. Kein Code-Change nötig in toggleCheck.
`clearCheckedItems` hingegen vergleicht Check-Keys mit der Ingredient-Tabelle via `LOWER(TRIM(COALESCE(i.unit, '')))` — das ist aber der RAW unit, nicht der Family-Key. Hier ist der Fix nötig.
→ Step 2 wird daher beide Facetten prüfen: (1) toggleCheck/round-trip funktioniert bereits (Test grün), (2) clearCheckedItems dedupliziert korrekt.
Ich füge daher einen expliziten clearCheckedItems-Test hinzu:
Append weitere Test-Case in denselben Block:
```typescript
it('clearCheckedItems respektiert family-key beim Orphan-Cleanup', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [
{ position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: null },
{ position: 2, name: 'Salz', quantity: 1, unit: 'Prise', note: null, raw_text: null }
]
})
);
addRecipeToCart(db, a, null);
const rows = listShoppingList(db).rows;
// Alle abhaken
for (const r of rows) toggleCheck(db, r.name_key, r.unit_key, true);
clearCheckedItems(db);
// Das Rezept sollte raus sein
expect(listShoppingList(db).recipes).toHaveLength(0);
// Check-Tabelle sollte leer sein (keine Orphans)
const remaining = (db.prepare('SELECT COUNT(*) AS c FROM shopping_cart_check').get() as { c: number }).c;
expect(remaining).toBe(0);
});
```
Run: `npm test -- tests/integration/shopping-repository.test.ts`
Expected: Der clearCheckedItems-Test könnte failen weil der Orphan-Cleanup mit raw-unit vergleicht — der Check hat 'weight', das Ingredient hat 'g', Key-Match schlägt fehl, Check bleibt als Orphan.
- [ ] **Step 3: Fix `clearCheckedItems` to use family-key for orphan comparison**
In `src/lib/server/shopping/repository.ts`, in `clearCheckedItems` den Orphan-Cleanup-Block:
Ersetzen (aktuell Zeilen 163-185):
```typescript
// Orphan-Checks raeumen: alle Check-Keys, die jetzt in KEINEM Cart-Rezept
// mehr vorkommen.
const activeKeys = db
.prepare(
`SELECT DISTINCT
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key
FROM shopping_cart_recipe cr
JOIN ingredient i ON i.recipe_id = cr.recipe_id`
)
.all() as { name_key: string; unit_key: string }[];
const activeSet = new Set(activeKeys.map((k) => `${k.name_key} ${k.unit_key}`));
const allChecks = db
.prepare('SELECT name_key, unit_key FROM shopping_cart_check')
.all() as { name_key: string; unit_key: string }[];
const del = db.prepare(
'DELETE FROM shopping_cart_check WHERE name_key = ? AND unit_key = ?'
);
for (const c of allChecks) {
if (!activeSet.has(`${c.name_key} ${c.unit_key}`)) {
del.run(c.name_key, c.unit_key);
}
}
```
durch:
```typescript
// Orphan-Checks raeumen: Active-Keys nach (name_key, unitFamily(raw-unit))
// bauen, damit Checks mit family-key korrekt gematcht werden.
const activeRaw = db
.prepare(
`SELECT DISTINCT
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key
FROM shopping_cart_recipe cr
JOIN ingredient i ON i.recipe_id = cr.recipe_id`
)
.all() as { name_key: string; unit_key: string }[];
const activeSet = new Set(
activeRaw.map((k) => `${k.name_key}|${unitFamily(k.unit_key)}`)
);
const allChecks = db
.prepare('SELECT name_key, unit_key FROM shopping_cart_check')
.all() as { name_key: string; unit_key: string }[];
const del = db.prepare(
'DELETE FROM shopping_cart_check WHERE name_key = ? AND unit_key = ?'
);
for (const c of allChecks) {
if (!activeSet.has(`${c.name_key}|${c.unit_key}`)) {
del.run(c.name_key, c.unit_key);
}
}
```
Analog den oberen Block in `clearCheckedItems` (perRecipe-Gruppierung, Zeilen 132-146), der `unit_key` mit `LOWER(TRIM(i.unit))` matched — da wird pro recipe_id gezählt, ob alle Zeilen abgehakt sind. Der Count-Vergleich mit `shopping_cart_check` erfolgt auch hier via unit_key. Anpassen:
Ersetzen (aktuell Zeilen 132-147):
```typescript
const allRows = db
.prepare(
`SELECT
cr.recipe_id,
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key,
EXISTS(
SELECT 1 FROM shopping_cart_check c
WHERE c.name_key = LOWER(TRIM(i.name))
AND c.unit_key = LOWER(TRIM(COALESCE(i.unit, '')))
) AS checked
FROM shopping_cart_recipe cr
JOIN ingredient i ON i.recipe_id = cr.recipe_id`
)
.all() as { recipe_id: number; name_key: string; unit_key: string; checked: 0 | 1 }[];
```
durch:
```typescript
// Rohe (name, unit)-Zeilen holen, checked-Status per Family-Key-Lookup
// in JS entscheiden (SQL-CASE-Duplikation vermeiden).
const allRowsRaw = db
.prepare(
`SELECT
cr.recipe_id,
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key
FROM shopping_cart_recipe cr
JOIN ingredient i ON i.recipe_id = cr.recipe_id`
)
.all() as { recipe_id: number; name_key: string; unit_key: string }[];
const checkSet = new Set(
(
db
.prepare('SELECT name_key, unit_key FROM shopping_cart_check')
.all() as { name_key: string; unit_key: string }[]
).map((c) => `${c.name_key}|${c.unit_key}`)
);
const allRows = allRowsRaw.map((r) => ({
recipe_id: r.recipe_id,
name_key: r.name_key,
unit_key: r.unit_key,
checked: checkSet.has(`${r.name_key}|${unitFamily(r.unit_key)}`) ? (1 as const) : (0 as const)
}));
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/integration/shopping-repository.test.ts`
Expected: Alle Tests grün.
- [ ] **Step 5: Run full suite + typecheck**
Run: `npm test && npm run check`
Expected:
- Tests: alle grün
- svelte-check: `0 ERRORS 0 WARNINGS`
- [ ] **Step 6: Commit**
```bash
git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): clearCheckedItems auf Family-Key umgestellt"
```
---
### Task 7: End-to-End-Smoketest im Dev-Deployment
**Files:** keine
- [ ] **Step 1: Push und warten auf CI-Deploy**
```bash
git push
```
CI baut arm64-Image, deployt nach dev. ~5 Min.
- [ ] **Step 2: Manuell auf `https://kochwas-dev.siegeln.net/shopping-list` prüfen**
Check-Liste:
- Zwei Rezepte mit 500 g + 1 kg gleicher Zutat in den Warenkorb → eine Zeile mit "1,5 kg".
- 400 ml + 0,5 l → "900 ml".
- Komma-Darstellung in Rezept-Detail überall ok (keine Regressionen).
- Abhaken + Rezept rausnehmen → Haken bleibt.
Wenn alle grün: Feature ist done. Kein separater Commit nötig.
---
## Self-Review Checklist
- [x] Spec-Coverage: Alle Sektionen abgedeckt (Unit-Konsolidierung → Task 1+2, Migration → Task 4, Formatter → Task 3, listShoppingList-Integration → Task 5, Check-Stabilität → Task 6).
- [x] Keine Placeholder: alle Tests und Implementierungen vollständig ausgeschrieben.
- [x] Type-Konsistenz: `QuantityInUnit`, `ShoppingListRow` einheitlich referenziert. `unit_key` bleibt derselbe Feldname, semantisch jetzt Family-Key.
- [x] Scope: eine einzelne Phase, atomic commits, TDD.

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.

View File

@@ -0,0 +1,295 @@
# Einkaufsliste — Design-Spec
**Datum**: 2026-04-21
**Status**: Spec, vor Implementierung
## Ziel
Aus Rezepten auf der Wunschliste eine flache, aggregierte Einkaufsliste erzeugen. Die Liste ist haushaltsweit geteilt, mobil-first, im Supermarkt abhakbar. Portionen sind pro Rezept anpassbar. Identische Zutaten (gleicher Name + gleiche Einheit) werden über mehrere Rezepte hinweg summiert.
## Entscheidungen (aus Brainstorming)
| Thema | Entscheidung |
|---|---|
| Sichtbarkeit | Global, eine Liste für alle Profile |
| Portionen | Default `servings_default` beim Hinzufügen; zentral auf der Einkaufslisten-Seite anpassbar |
| Aggregation | Flache Liste, exaktes Matching auf `(LOWER(TRIM(name)), LOWER(TRIM(unit)))`. Keine Fuzzy-Matches — lieber zwei Zeilen als falsche Summen. Rezept-Herkunft pro Zeile sichtbar. |
| Abhaken | Checkbox, durchgestrichen, sortiert ans Ende. Manuelles Cleanup via „Erledigte entfernen" / „Liste leeren" |
| Kopplung | Komplett entkoppelt von Wunschliste und `cooking_log`. Abhaken beeinflusst nur die Einkaufsliste. |
| Header-Badge | Zählt **nicht-abgehakte** aggregierte Zutaten-Zeilen. Versteckt sich bei Count = 0. |
| Manuelle Einträge | Out of scope. Nur rezeptbasiert. |
## Datenmodell
Migration `013_shopping_list.sql`:
```sql
CREATE TABLE shopping_cart_recipe (
recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
servings INTEGER NOT NULL,
added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE shopping_cart_check (
name_key TEXT NOT NULL,
unit_key TEXT NOT NULL,
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (name_key, unit_key)
);
```
**Derivation-Prinzip**: Die aggregierte Liste wird **nicht materialisiert**. Sie wird bei jedem Lesen aus `shopping_cart_recipe JOIN recipe JOIN ingredient` plus Skalierungs-Faktor berechnet. Vorteil: Rezept-Edits wirken live auf die Liste.
**Abhaken pro aggregierter Zeile**: `(name_key, unit_key)` — nicht pro Rezept-Zutat. Wenn zwei Rezepte beide „Mehl, g" haben, gibt es eine Zeile „400 g Mehl", und ein Haken reicht. Wird eines der Rezepte entfernt, bleibt „200 g Mehl" mit Haken sichtbar.
**Orphan-Checks** (aggregierter Schlüssel ist nicht mehr durch ein Rezept im Cart abgedeckt): Werden nicht aktiv gelöscht, tauchen aber in der Ausgabe von `listShoppingList` nicht auf (der Join erzeugt keine Zeile). Späteres Cleanup optional via `clearCart` / `clearCheckedItems`.
### Aggregations-SQL (Kern)
```sql
SELECT
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key,
MIN(i.name) AS display_name,
MIN(i.unit) AS display_unit,
SUM(i.quantity * cr.servings * 1.0 / r.servings_default) AS total_quantity,
GROUP_CONCAT(DISTINCT r.title) AS from_recipes,
EXISTS(SELECT 1 FROM shopping_cart_check c
WHERE c.name_key = LOWER(TRIM(i.name))
AND c.unit_key = LOWER(TRIM(COALESCE(i.unit, '')))) AS checked
FROM shopping_cart_recipe cr
JOIN recipe r ON r.id = cr.recipe_id
JOIN ingredient i ON i.recipe_id = r.id
GROUP BY name_key, unit_key
ORDER BY checked ASC, display_name COLLATE NOCASE;
```
**Edge Cases**:
- `i.quantity IS NULL``total_quantity` bleibt NULL, UI rendert ohne Mengenangabe.
- `r.servings_default IS NULL` → Division through-by-NULL → `total_quantity` NULL; defensiver: `COALESCE(r.servings_default, cr.servings)` (Faktor = 1, wenn kein Default bekannt).
- `i.unit IS NULL``unit_key = ''`, Anzeige ohne Einheit.
- Rezept hat keine Zutaten (sehr selten) → kein Beitrag zur Liste, Rezept-Chip erscheint trotzdem (Signal: „ups, keine Zutaten").
## Server-Module
### `src/lib/server/shopping/repository.ts`
Neue Typen:
```ts
export type ShoppingCartRecipe = {
recipe_id: number;
title: string;
image_path: string | null;
servings: number;
servings_default: number;
};
export type ShoppingListRow = {
name_key: string;
unit_key: string;
display_name: string;
display_unit: string | null;
total_quantity: number | null;
from_recipes: string; // comma-separated recipe titles
checked: 0 | 1;
};
export type ShoppingListSnapshot = {
recipes: ShoppingCartRecipe[];
rows: ShoppingListRow[];
uncheckedCount: number;
};
```
Funktionen:
- `addRecipeToCart(db, recipeId, profileId, servings?)``INSERT … ON CONFLICT(recipe_id) DO UPDATE SET servings = excluded.servings`. Wenn `servings` fehlt, nimmt `COALESCE(recipe.servings_default, 4)`.
- `removeRecipeFromCart(db, recipeId)`
- `setCartServings(db, recipeId, servings)` — App-seitig validiert: `1 ≤ servings ≤ 50`. SQL-Level `CHECK (servings > 0)` zusätzlich als Sicherheitsnetz.
- `listShoppingList(db) → ShoppingListSnapshot` — liefert Cart-Rezepte, aggregierte Zeilen und `uncheckedCount` in einer Transaktion.
- `toggleCheck(db, nameKey, unitKey, checked: boolean)` — Insert bzw. Delete in `shopping_cart_check`.
- `clearCheckedItems(db)` — transaktional:
1. Aggregation laufen lassen und `recipe_id`s finden, deren sämtliche aggregierten Zeilen abgehakt sind (ein Rezept zählt als „erledigt", wenn all seine `(name_key, unit_key)`-Beiträge in `shopping_cart_check` stehen)
2. Diese Rezepte via `DELETE FROM shopping_cart_recipe WHERE recipe_id IN (…)` entfernen
3. Check-Einträge, die jetzt keinen Bezug mehr haben, mit `DELETE FROM shopping_cart_check WHERE (name_key, unit_key) NOT IN (<aktive Keys nach Step 2>)` aufräumen
- `clearCart(db)``DELETE FROM shopping_cart_recipe; DELETE FROM shopping_cart_check;`
### Routen
| Methode + Pfad | Body/Params | Zweck |
|---|---|---|
| `GET /api/shopping-list` | — | Snapshot holen |
| `POST /api/shopping-list/recipe` | `{ recipe_id, servings?, profile_id? }` | Rezept in Cart; idempotent |
| `PATCH /api/shopping-list/recipe/:recipe_id` | `{ servings }` | Portionen ändern |
| `DELETE /api/shopping-list/recipe/:recipe_id` | — | Rezept raus |
| `POST /api/shopping-list/check` | `{ name_key, unit_key }` | Abhaken |
| `DELETE /api/shopping-list/check` | `{ name_key, unit_key }` | Haken weg |
| `DELETE /api/shopping-list/checked` | — | Erledigte entfernen |
| `DELETE /api/shopping-list` | — | Liste leeren |
Error-Handling: 404 wenn `recipe_id` nicht im Cart (nur bei DELETE/PATCH auf spezifischem Rezept), 400 bei Validation-Fehlern (servings ≤ 0, fehlende Felder), 500 mit JSON-Body `{ message }` bei DB-Fehlern.
## Client-Store
`src/lib/client/shopping-cart.svelte.ts` — analog zu `wishlist.svelte.ts`:
```ts
class ShoppingCartStore {
uncheckedCount = $state(0);
recipeIds = $state<Set<number>>(new Set()); // für „ist dieses Rezept im Cart?"
loaded = $state(false);
async refresh(): Promise<void>;
async addRecipe(recipeId: number): Promise<void>;
async removeRecipe(recipeId: number): Promise<void>;
isInCart(recipeId: number): boolean;
}
```
- `refresh()` ruft `GET /api/shopping-list` auf und extrahiert `recipeIds` + `uncheckedCount` aus dem Snapshot. Ein separater Leichtgewichts-Count-Endpoint ist nicht nötig; der Snapshot ist klein.
- Store wird in `+layout.svelte` beim `onMount` initialisiert (wie `wishlistStore.refresh()`).
- Nach jedem Mutating-Call (add/remove/toggle/clear) wird `refresh()` vom aufrufenden Code getriggert.
## UI
### (a) Wunschlisten-Karte — Relayout
Aktuell drücken zwei rechts-gestapelte Buttons den Titel-Text auf Handys zusammen. Neues Layout:
```
┌──────────┬─────────────────────────────┐
│ │ [Utensils|3] [Cart] [Trash] │ Action-Leiste oben, horizontal
│ Bild │ Titel (fett, 2 Zeilen max) │
│ 96px │ Hendrik, Verena, Leana │ wanted_by + ★
│ │ ★ 4.5 │
└──────────┴─────────────────────────────┘
```
Konkret in `src/routes/wishlist/+page.svelte`:
- `.actions` wird horizontal, als erste Zeile über dem Titel rechts-bündig.
- `source_domain`-Span aus der `.meta`-Zeile entfernt (Platz).
- Neuer Cart-Button zwischen Utensils und Trash:
- Nicht im Cart: neutral (Icon grau), aria-label „In den Einkaufswagen"
- Im Cart: grün gefüllt, Häkchen-Badge unten rechts, aria-label „Aus Einkaufswagen entfernen"
- Alle drei Buttons ≥ 44 × 44 px (mobile Tap-Target).
Vergleichbare Reorg in `src/routes/recipes/[id]/+page.svelte` nötig? — **Nein**. Der Cart-Button erscheint nur auf der Wunschliste. (Begründung: Rezept-Detail hat schon ein volles Action-Menü; das Hinzufügen zum Cart passiert bewusst aus der Wunschlisten-Perspektive.)
### (b) Header-Badge
`src/routes/+layout.svelte` — rechts neben dem bestehenden Kochtopf-Icon:
- Icon `ShoppingCart` aus `lucide-svelte`
- Badge-Kreis oben rechts mit `shoppingCartStore.uncheckedCount`
- Nur sichtbar wenn `uncheckedCount > 0`
- Klick → `goto('/shopping-list')`
- Gleicher Visual-Style wie der CookingPot (Farb-Konsistenz grün)
### (c) Seite `/shopping-list`
Datei: `src/routes/shopping-list/+page.svelte`
```
┌──────────────────────────────────────┐
│ Einkaufsliste │ Header
│ 12 noch zu besorgen · 3 Rezepte │
├──────────────────────────────────────┤
│ [Carbonara 4p- +] [Lasagne 6p- +] … │ Rezept-Chips, horizontal scrollbar
│ │ (Titel + Portions-Stepper + X)
├──────────────────────────────────────┤
│ ☐ 400 g Mehl │
│ aus Carbonara, Lasagne │
│ ☐ 6 Stk Eier │
│ aus Carbonara │
│ … │
│ ☑ 200 g Butter (durchgestrichen) │ Abgehakt, ans Ende
├──────────────────────────────────────┤
│ [Erledigte entfernen] [Liste leeren] │ Sticky Footer
└──────────────────────────────────────┘
```
**Komponenten** (neue Svelte-Dateien):
- `src/lib/components/ShoppingCartChip.svelte` — Rezept-Chip mit Stepper + Remove
- `src/lib/components/ShoppingListRow.svelte` — eine Zutatenzeile mit Checkbox
**Portions-Stepper**: - und + Buttons, mittig die Zahl. Min 1, Max 50 (sanity). Klick sendet PATCH, triggert Store-Refresh → Liste rerendert.
**Zutaten-Reihenfolge**: Erst nicht-abgehakt, dann abgehakt; innerhalb jeder Gruppe alphabetisch (`display_name COLLATE NOCASE`). Abgehakt = durchgestrichen + grauer Text.
**Mengen-Formatierung** (`src/lib/quantity-format.ts`, neu):
- `formatQuantity(q: number | null): string`
- `null``''`
- Ganz-nahe-Ganzzahl (Epsilon 0.01) → Integer
- Sonst auf max. 2 Nachkommastellen, trailing Nullen weg
- Beispiele: `400 → "400"`, `0.5 → "0.5"`, `0.333 → "0.33"`, `null → ""`
**Aktionen im Footer**:
- „Erledigte entfernen" — sichtbar wenn ≥ 1 Check, kein Confirm (reversibel genug)
- „Liste leeren" — Confirm via `confirmAction`: „Komplette Einkaufsliste löschen? Das macht nicht rückgängig."
**Empty State**: Icon `ShoppingCart` (große Version), „Einkaufswagen ist leer", Hint „Lege Rezepte auf der Wunschliste in den Wagen, um sie hier zu sehen."
**Offline-Verhalten**: Wie die Wunschliste — alle Mutating-Calls via `requireOnline()`. Service-Worker cached nichts von `/api/shopping-list/*` (network-only analog zu Wishlist). Die PWA-Seite selbst wird vom SW-Shell-Cache serviert, aber ohne Daten. Offline-Robustheit (local queue + sync) ist **out of scope** für v1.
## Testing
### Unit/Integration-Tests (Vitest, in-memory DB)
- `tests/integration/shopping-repository.test.ts`:
- `addRecipeToCart` idempotent, `ON CONFLICT` überschreibt `servings`
- Aggregation: gleiche `(name_key, unit_key)` summiert; unterschiedliche unit_keys bleiben getrennt
- Portions-Skalierung: `servings_default=4`, `servings=2` → alle Mengen halbiert
- Nulls: `quantity IS NULL``total_quantity IS NULL`; `unit IS NULL``unit_key=''`
- `toggleCheck` persistiert über `listShoppingList`-Aufrufe
- Abgehakt-Status überlebt Entfernen eines Rezepts, solange Schlüssel von einem anderen kommt
- `clearCheckedItems`: entfernt nur vollständig abgehakte Rezepte + räumt Orphan-Checks
- `countUncheckedItems` nach diversen Ops korrekt
- `clearCart` cleant beide Tabellen
- `tests/unit/shopping-cart-store.test.ts`:
- Mock-Fetch, testet refresh-Trigger nach add/remove
- `isInCart(id)` reflektiert aktuellen Zustand
- `uncheckedCount` reactive nach refresh
- `tests/unit/quantity-format.test.ts`:
- `formatQuantity(400) === "400"`
- `formatQuantity(0.5) === "0.5"`
- `formatQuantity(0.333333) === "0.33"`
- `formatQuantity(400.001) === "400"` (Epsilon)
- `formatQuantity(null) === ""`
### E2E-Tests (Playwright, `tests/e2e/remote/shopping.spec.ts`)
**Wichtig**: E2E-Tests laufen gegen `kochwas-dev.siegeln.net` und erfordern einen erfolgreichen Deploy des Features. Werden nach dem Feature-Merge manuell ausgelöst, nicht im Rahmen der Implementierungs-Phase.
Abgedeckt:
- Rezept auf Wunschliste → Cart-Button klicken → Header-Badge erscheint
- Navigation zu `/shopping-list`, Portions-Stepper hoch/runter → Zutatenmengen reagieren
- Zutat abhaken → Badge-Count sinkt, Zeile durchgestrichen, Reload persistiert
- „Erledigte entfernen" → vollständig abgehakte Rezepte weg, teilweise abgehakte bleiben
- „Liste leeren" → Empty-State, Badge verschwindet
- Zwei Rezepte mit gleicher Zutat (Fixture-Setup) → aggregierte Zeile mit Summe
- Cleanup-Fixture entfernt Cart + Checks nach jedem Test
**Nicht getestet**: exakte CSS-Styles, Animationen — visuelle Kontrolle beim Deploy.
## Implementierungs-Reihenfolge (Hinweis für Plan)
1. Migration 013 + Repository + Unit-Tests
2. API-Routen + Integrationstests
3. Client-Store
4. Header-Badge-Icon
5. Wunschlisten-Karte Relayout + Cart-Button
6. Seite `/shopping-list` (Chips → Rows → Footer → Empty State)
7. Quantity-Formatter + Tests
8. Service-Worker network-only für `/api/shopping-list/*`
9. Deploy, dann E2E-Tests nachschieben
## Out of Scope (für v1)
- Manuelle Einträge („Klopapier")
- Supermarkt-Abteilungs-Sortierung
- Offline-Queue (add/check während offline, sync später)
- Synonym/Fuzzy-Matching von Zutaten-Namen (der User harmonisiert langfristig händisch)
- Auto-Kopplung zu `cooking_log` / Wunschliste-Remove
- Teilen per Link / Export

View File

@@ -0,0 +1,208 @@
# Einkaufsliste: Mengen-Konsolidierung über Einheiten
## Kontext
Die Einkaufsliste (`/src/lib/server/shopping/repository.ts`, `listShoppingList()`) aggregiert
Zutaten aus allen Warenkorb-Rezepten dynamisch per `GROUP BY LOWER(TRIM(name)), LOWER(TRIM(unit))`
und summiert die skalierten Mengen. Verschiedene Einheiten für dieselbe Zutat bleiben separate
Zeilen — typisches Beispiel: `500 g Kartoffeln` (Rezept A) und `1 kg Kartoffeln` (Rezept B)
erscheinen als zwei Zeilen. Gewünscht: beides konsolidiert zu `1,5 kg Kartoffeln`.
## Design-Entscheidungen (durch Brainstorming bestätigt)
- **Scope**: nur Gewicht (g ↔ kg) und Volumen (ml ↔ l). TL/EL/Tasse/Stück bleiben unverändert.
- **Anzeige-Einheit**: Auto-Promote ab ≥ 1000 in Basis-Einheit (500 g + 1 kg → "1,5 kg",
200 g + 300 g → "500 g", 400 ml + 0,5 l → "900 ml", 0,5 l + 0,8 l → "1,3 l").
- **Formatter**: `formatQuantity` wechselt app-weit auf `toLocaleString('de-DE', …)`
deutsches Komma als Dezimaltrennzeichen überall, kein Tausender-Grouping.
- **Check-Stabilität**: der „abgehakt"-State hängt künftig an der Unit-Family (weight / volume /
raw-unit), nicht an einer Display-Einheit, damit Hin-und-her-Wechsel zwischen g und kg den
Haken nicht verlieren.
## Sektion 1 — Unit-Konsolidierung
### Neue Utility: `src/lib/server/shopping/unit-consolidation.ts`
Zwei reine Funktionen, vollständig getestet per Unit-Tests:
```ts
export type UnitFamily = 'weight' | 'volume' | string;
const WEIGHT_UNITS = new Set(['g', 'kg']);
const VOLUME_UNITS = new Set(['ml', 'l']);
export function unitFamily(unit: string | null | undefined): UnitFamily {
const u = (unit ?? '').trim().toLowerCase();
if (WEIGHT_UNITS.has(u)) return 'weight';
if (VOLUME_UNITS.has(u)) return 'volume';
return u; // leer bleibt leer → eigene Gruppe
}
export interface QuantityInUnit {
quantity: number | null;
unit: string | null;
}
export function consolidate(rows: QuantityInUnit[]): QuantityInUnit {
// Gewicht: in g summieren, ≥1000 → kg, sonst g
// Volumen: in ml summieren, ≥1000 → l, sonst ml
// Andere: quantity einfach summieren, unit vom ersten Eintrag
// (alle rows einer Gruppe haben dieselbe Family = denselben unit-string)
// quantity=null wird als 0 behandelt (z. B. "etwas Salz" + "1 TL Salz" → "1 TL")
}
```
**Rundung Promote-Schwelle**: Vergleich passiert auf summierter Basis-Einheit
(z. B. 1500 g ≥ 1000 → kg). Ergebnis-Rundung: `Math.round(x * 100) / 100` (max.
zwei Nachkommastellen), die finale Display-Formatierung macht `formatQuantity`.
**Edge-Cases, die expliziter Test-Fall sind**:
- `500 g + 1 kg``{quantity: 1.5, unit: 'kg'}`
- `200 g + 300 g``{quantity: 500, unit: 'g'}`
- `400 ml + 0.5 l``{quantity: 900, unit: 'ml'}`
- `0.5 l + 0.8 l``{quantity: 1.3, unit: 'l'}`
- `2 Bund + 1 Bund``{quantity: 3, unit: 'Bund'}` (unchanged family)
- `5 Stück + 3 Stück``{quantity: 8, unit: 'Stück'}`
- `null + 1 TL Salz` (eine Menge unbekannt) → `{quantity: 1, unit: 'TL'}`
- `null + null``{quantity: null, unit: '<leer oder erster unit>'}`
### Integration in `listShoppingList()`
Die existierende SQL-Query liefert schon skalierte Mengen pro Zutat-Zeile
(quantity * servings / servings_default). Änderung:
1. **GROUP BY** der SQL-Query wechselt von `LOWER(TRIM(unit))` auf einen
Family-Key (inline per `CASE`):
```sql
GROUP BY LOWER(TRIM(name)),
CASE LOWER(TRIM(unit))
WHEN 'g' THEN 'weight'
WHEN 'kg' THEN 'weight'
WHEN 'ml' THEN 'volume'
WHEN 'l' THEN 'volume'
ELSE LOWER(TRIM(unit))
END
```
2. **SUM()** wird nicht mehr blind über quantity gerechnet (500 + 1 ≠ 1500
in Basis). Stattdessen liefert SQL pro Gruppe eine Liste der einzelnen
`(quantity, unit)`-Paare — z. B. via `json_group_array(json_object('quantity', q, 'unit', u))`.
TypeScript ruft dann `consolidate()` pro Zeile auf.
Alternative: SQL liefert für Familien 'weight' und 'volume' schon die
summierten Basis-Werte (via `SUM(q * CASE WHEN unit='kg' THEN 1000 ELSE 1 END)`),
für andere Families die unveränderte `SUM(q)`. Spart den json_group_array-Trick,
ist aber in SQL hässlich. **Empfehlung**: json_group_array + consolidate in TS —
SQL bleibt lesbar, Logik testbar.
3. Der Rückgabewert `ShoppingListItem` bekommt zwei zusätzliche Felder (wenn
nicht schon vorhanden):
- `quantity: number | null` (finaler Display-Wert)
- `unit: string | null` (finale Display-Einheit)
- `unitFamilyKey: string` (für den Check-Lookup clientseitig)
## Sektion 2 — Check-Key-Stabilität
Aktuelle Tabelle (aus `013_shopping_list.sql`):
```sql
CREATE TABLE shopping_cart_check (
name_key TEXT NOT NULL,
unit_key TEXT NOT NULL,
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (name_key, unit_key)
);
```
### Neue Migration `015_shopping_check_family.sql`
```sql
-- Unit-Key wird zum Family-Key: g/kg → 'weight', ml/l → 'volume', sonst lowercased unit.
-- Wir migrieren bestehende Einträge damit alte Abhaks gültig bleiben.
UPDATE shopping_cart_check SET unit_key = 'weight' WHERE unit_key IN ('g', 'kg');
UPDATE shopping_cart_check SET unit_key = 'volume' WHERE unit_key IN ('ml', 'l');
-- Nach Umetikettierung können Duplikate entstehen (z. B. zwei Einträge mit
-- 'weight' für dieselbe Zutat). Deduplizieren: jüngsten behalten.
DELETE FROM shopping_cart_check
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM shopping_cart_check
GROUP BY name_key, unit_key
);
```
### Code-Änderungen
- `listShoppingList()`: beim Joinen von `shopping_cart_check` mit den aggregierten
Zeilen matched jetzt `(name_key, unit_family_key)` statt `(name_key, unit_key)`.
- `toggleCheck(name, unit, checked)`: speichert/löscht Check mit
`unitFamily(unit)` statt raw unit.
## Sektion 3 — Display-Formatter
### `src/lib/quantity-format.ts`
```ts
export function formatQuantity(q: number | null): string {
if (q === null || q === undefined) return '';
return q.toLocaleString('de-DE', {
maximumFractionDigits: 2,
useGrouping: false
});
}
```
Kleinere Datei, dieselbe Semantik (max. 2 Dezimalen, ganze Zahlen ohne Dezimal),
plus deutsches Dezimalkomma app-weit.
### Test-Anpassung `tests/unit/quantity-format.test.ts`
Erwartungswerte von `"0.33"` auf `"0,33"` etc. ziehen. Bestehende 5 Tests müssen mit.
## Sektion 4 — Tests
### Neu: `tests/unit/unit-consolidation.test.ts`
Alle Edge-Cases aus Sektion 1 als expect-Assertions. Plus: `unitFamily`-Table-Tests.
### Ergänzung: `tests/integration/shopping-repository.test.ts`
Ein neuer `describe`-Block „konsolidiert über Einheiten":
- Rezept A mit `500 g Kartoffeln`, Rezept B mit `1 kg Kartoffeln` → eine Zeile
`{name: 'kartoffeln', quantity: 1.5, unit: 'kg'}`.
- Analog Volumen mit ml + l.
- Gemischte Units wie `2 Bund Petersilie + 1 Bund Petersilie` → eine Zeile `3 Bund`.
- `5 Stück Eier + 500 g Eier` → **zwei** Zeilen (verschiedene Families).
- Abhaken einer konsolidierten kg-Zeile → nach Entfernung eines Rezepts (jetzt nur
noch 800 g) bleibt die Zeile abgehakt (Family = 'weight' stabil).
### Ergänzung: Migration-Test
Ein kleiner Test ähnlich dem Stil anderer Migration-Tests im Repo, der verifiziert:
- Alt-Einträge `(milch, 'ml')` und `(milch, 'l')` kollabieren zu einem `(milch, 'volume')`.
- Unveränderte Einträge wie `(petersilie, 'bund')` bleiben.
## Was explizit NICHT dabei ist (YAGNI)
- **Fuzzy-Name-Matching** (Kartoffel vs Kartoffeln, „Zwiebeln, rot" vs „rote Zwiebeln") —
ausgeschlossen, hohe Fehlerrate.
- **Stück-zu-Gramm-Mappings** (1 Zwiebel ≈ 80 g) — semantisch fraglich, nicht deterministisch.
- **TL/EL/Tasse-Konvertierung** — Einkauft man nicht in.
- **User-editierbare Custom-Units** — Overkill für eine Familien-PWA.
- **UI-Anzeige der zugrundeliegenden Einzelmengen** („1,5 kg — aus 500 g + 1 kg") — wäre
nett, aber nicht notwendig für die Hauptfunktion.
## Phase-Gliederung (für die spätere writing-plans-Phase)
Eine Phase reicht aus:
1. `unit-consolidation.ts` + Unit-Tests
2. `quantity-format.ts` auf `toLocaleString` umbauen + Tests updaten
3. Migration `015_shopping_check_family.sql`
4. `listShoppingList()` integriert Konsolidierung + Check-Join
5. `toggleCheck()` auf Family-Key umstellen
6. Integration-Tests
Alles in einer Phase, weil Änderungen eng verzahnt sind (Migration + Repository + Formatter
müssen zusammen deployt werden, sonst gibt es UI-Inkonsistenzen).

View File

@@ -0,0 +1,244 @@
# Hauptseite: "Zuletzt angesehen" Sort + Collapsible Sections
## Kontext
Die Hauptseite (`src/routes/+page.svelte`) hat heute drei Sektionen — "Deine
Favoriten", "Zuletzt hinzugefügt", "Alle Rezepte" — und vier Sort-Optionen
für "Alle Rezepte" (Name, Bewertung, Zuletzt gekocht, Hinzugefügt). Der
User möchte:
1. Eine fünfte Sort-Option "Zuletzt angesehen" für "Alle Rezepte"
2. "Deine Favoriten" und "Zuletzt hinzugefügt" auf-/zuklappbar machen
Beides reduziert visuelle Last und gibt Zugriff auf "kürzlich
beschäftigte mich" Rezepte ohne Suche.
## Design-Entscheidungen (durch Brainstorming bestätigt)
- **View-Tracking**: zählt sofort beim Laden der Detailseite — kein Threshold
- **Storage**: SQLite, pro Profil (konsistent mit Ratings, Cooked, Wishlist)
- **Collapsibles**: standardmäßig offen, User-Wahl persistiert pro Device
## Sektion 1 — Schema & View-Tracking
### Migration
Neue Datei `src/lib/server/db/migrations/014_recipe_view.sql`
(Numbering: aktuell ist die letzte Migration `013_shopping_list.sql`):
```sql
CREATE TABLE recipe_view (
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
last_viewed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (profile_id, recipe_id)
);
CREATE INDEX idx_recipe_view_recent
ON recipe_view(profile_id, last_viewed_at DESC);
```
Idempotent über `INSERT OR REPLACE` — mehrfache Visits ein- und desselben
Profils auf dasselbe Rezept führen nur zur Aktualisierung des Timestamps,
kein Multi-Insert.
Cascade auf beide FKs: löscht ein User ein Rezept oder ein Profil, gehen
zugehörige Views automatisch mit.
### API
Neuer Endpoint `POST /api/recipes/[id]/view`:
```
Request body: { "profile_id": number }
Response: 204 No Content
Errors:
- 400 wenn profile_id fehlt oder kein Number
- 404 wenn Recipe nicht existiert (FK-Violation)
- 404 wenn Profil nicht existiert (FK-Violation)
```
Implementation: einfache `INSERT OR REPLACE` mit den IDs. `last_viewed_at`
nutzt den Default (`datetime('now')`).
### Client-Hook
In `src/routes/recipes/[id]/+page.svelte`, in `onMount`:
```ts
if (profileStore.active) {
void fetch(`/api/recipes/${recipe.id}/view`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileStore.active.id })
});
}
```
Fire-and-forget, kein UI-Block, kein Error-Handling — wenn der Beacon
fehlschlägt, ist es kein User-Visible-Bug, das nächste View korrigiert
es.
## Sektion 2 — Sort "Zuletzt angesehen"
### Page
In `src/routes/+page.svelte`:
```ts
type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
const ALL_SORTS = [
...,
{ value: 'viewed', label: 'Zuletzt angesehen' }
];
```
### API
`GET /api/recipes/all` bekommt einen optionalen `profile_id`-Query-Param.
Der Endpoint reicht ihn an `listAllRecipesPaginated` durch.
### DB-Layer
`listAllRecipesPaginated` in `src/lib/server/recipes/search-local.ts`
bekommt einen optionalen `profileId: number | null`-Parameter. Wenn
`sort === 'viewed'` UND `profileId !== null`:
```sql
SELECT r.*, ...
FROM recipes r
LEFT JOIN recipe_view v
ON v.recipe_id = r.id AND v.profile_id = :profileId
ORDER BY v.last_viewed_at DESC NULLS LAST,
r.title COLLATE NOCASE ASC
LIMIT :limit OFFSET :offset
```
Bei `sort === 'viewed'` ohne `profileId`: fällt auf alphabetische
Sortierung zurück (kein Crash, sinnvolles Default-Verhalten).
### Reactive Refetch bei Profile-Switch
Auf Home-Page-Ebene: ein `$effect` der auf `profileStore.activeId` lauscht
und — wenn `allSort === 'viewed'``setAllSort('viewed')` retriggert
(forciert Refetch mit neuem profile_id). Sonst (anderer Sort) keine
Aktion, weil andere Sorts nicht profilabhängig sind.
### Snapshot-Kompatibilität
Der existierende `rehydrateAll(sort, count, exhausted)` in `+page.svelte`
muss `profile_id` mitschicken, sonst zeigt der Back-Nav für sort='viewed'
einen anderen Inhalt als vor dem Forward-Klick. Das gleiche gilt für
`loadAllMore` und `setAllSort`.
## Sektion 3 — Auf-/Zuklappbare Sektionen
### State
In `src/routes/+page.svelte`:
```ts
type CollapseKey = 'favorites' | 'recent';
let collapsed = $state<Record<CollapseKey, boolean>>({
favorites: false,
recent: false
});
const STORAGE_KEY = 'kochwas.collapsed.sections';
function toggle(key: CollapseKey) {
collapsed[key] = !collapsed[key];
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed));
}
```
In `onMount`: aus localStorage parsen, fehlerhafte JSON ignorieren
(default-state behalten).
### Markup
Pro Sektion:
```svelte
<section class="listing">
<button
class="section-head"
onclick={() => toggle('favorites')}
aria-expanded={!collapsed.favorites}
>
<ChevronDown size={18} class:rotated={collapsed.favorites} />
<h2>Deine Favoriten</h2>
<span class="count">{favorites.length}</span>
</button>
{#if !collapsed.favorites}
<div transition:slide={{ duration: 180 }}>
<ul class="cards"></ul>
</div>
{/if}
</section>
```
### Visual / CSS
- Header `<button>`: transparenter Border, full-width, `display: flex`,
`align-items: center`, `gap: 0.5rem`, `min-height: 44px` (Tap-Target)
- Chevron-Icon (lucide-svelte `ChevronDown`): rotiert auf
`transform: rotate(-90deg)` wenn `.rotated`
- Count-Pill rechts: kleiner grauer Text, hilft zu sehen wie viel hinter
einer zugeklappten Sektion steckt
- Hover: leichter Hintergrund (`#f4f8f5`, wie andere interaktive Elemente)
- Animation: `svelte/transition`'s `slide`, ~180 ms
### Persistenz-Format
```json
{ "favorites": false, "recent": true }
```
Truthy = collapsed. Default-Zustand wenn key fehlt: beide false.
### "Alle Rezepte" bleibt nicht-collapsible
Hauptliste, immer sichtbar — User würde das Scrollen verlieren.
## Test-Strategie
### Schema/Migration
- Migrations-Test (existierendes Pattern in `tests/integration`): nach
`applyMigrations` muss `recipe_view` existieren mit erwarteten
Spalten
### View-Endpoint
- `POST /api/recipes/[id]/view` Integration-Test:
- Erstes POST → Row mit `last_viewed_at` ungefähr `now`
- Zweites POST → gleiche Row, `last_viewed_at` aktualisiert
- POST mit ungültiger profile_id → 404
- POST mit ungültiger recipe_id → 404
- POST ohne profile_id im Body → 400
### Sort-Logik
- Unit-Test für `listAllRecipesPaginated(db, 'viewed', limit, offset, profileId)`:
- Mit Views-Daten: angesehene Rezepte zuerst (DESC nach `last_viewed_at`),
Rest alphabetisch
- Ohne profileId: fallback auf alphabetisch
- Mit profileId aber ohne Views: alle als NULL → alphabetisch
### Collapsibles (manuell oder unit)
- localStorage-Persistenz: Toggle, Reload, gleicher State
- Default-State wenn localStorage leer/corrupt: beide offen
- Ein Unit-Test für eine reine Helper-Funktion (parse/serialize), Markup
ist Snapshot-mässig nicht so wertvoll testbar
## Reihenfolge der Umsetzung
1. Migration + DB-Layer + Sort-Query (`search-local.ts`-Erweiterung)
2. View-Endpoint (`POST /api/recipes/[id]/view`) + Client-Beacon in
`recipes/[id]/+page.svelte`
3. Sort-Option in `+page.svelte` UI + API-Param weiterreichen +
profile_id in `loadAllMore`/`rehydrateAll`/`setAllSort` durchreichen
4. Collapsible-Pattern in `+page.svelte` für Favoriten und Recent
Jede Phase atomar committen + pushen.

836
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "kochwas", "name": "kochwas",
"version": "1.2.0", "version": "1.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -35,12 +35,15 @@
"vitest": "^2.1.4" "vitest": "^2.1.4"
}, },
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"linkedom": "^0.18.5", "linkedom": "^0.18.5",
"lucide-svelte": "^1.0.1", "lucide-svelte": "^1.0.1",
"node-addon-api": "^8.7.0",
"node-gyp": "^12.3.0",
"yauzl": "^3.3.0", "yauzl": "^3.3.0",
"zod": "^3.23.8" "zod": "^3.23.8"
} }

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,78 @@
// Persistent scroll restoration across client navigations.
//
// SvelteKit only restores scroll synchronously after the new page mounts.
// Pages whose content is fetched in onMount/afterNavigate (e.g. home,
// wishlist, shopping-list) are still empty at that point, so the saved
// scrollY can't be reached and the browser clamps to 0.
//
// We patch this by saving scrollY on beforeNavigate (keyed by the URL
// we're leaving — NOT location.pathname, which on popstate is already
// the new URL by the time the callback fires) and re-applying it after
// popstate as soon as the document is tall enough — rAF-polled with a
// hard time budget so we never spin.
const STORAGE_KEY = 'kochwas:scroll';
const POLL_BUDGET_MS = 1500;
const MIN_RESTORE_Y = 40; // ignore noise: don't override a default top scroll
type ScrollMap = Record<string, number>;
function readMap(): ScrollMap {
if (typeof sessionStorage === 'undefined') return {};
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as ScrollMap) : {};
} catch {
return {};
}
}
function writeMap(map: ScrollMap): void {
if (typeof sessionStorage === 'undefined') return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch {
// quota exceeded — silently drop, scroll memory is best-effort
}
}
function keyFor(url: URL): string {
return url.pathname + url.search;
}
export function recordScroll(fromUrl: URL | null | undefined): void {
if (typeof window === 'undefined') return;
if (!fromUrl) return;
const map = readMap();
map[keyFor(fromUrl)] = window.scrollY;
writeMap(map);
}
export function restoreScroll(
navType: string | null | undefined,
toUrl: URL | null | undefined
): void {
if (typeof window === 'undefined') return;
if (navType !== 'popstate') return;
if (!toUrl) return;
const target = readMap()[keyFor(toUrl)];
if (!target || target < MIN_RESTORE_Y) return;
const start = performance.now();
const step = () => {
const docHeight = document.documentElement.scrollHeight;
const reachable = Math.max(0, docHeight - window.innerHeight);
if (reachable >= target - 4) {
window.scrollTo({ top: target, left: 0, behavior: 'instant' });
return;
}
if (performance.now() - start >= POLL_BUDGET_MS) {
// Best effort — content never grew tall enough; clamp will land us
// at the bottom of what's available.
window.scrollTo({ top: target, left: 0, behavior: 'instant' });
return;
}
requestAnimationFrame(step);
};
requestAnimationFrame(step);
}

View File

@@ -17,7 +17,7 @@ export type SearchStoreOptions = {
debounceMs?: number; debounceMs?: number;
filterDebounceMs?: number; filterDebounceMs?: number;
minQueryLength?: number; minQueryLength?: number;
filterParam?: () => string; webFilterParam?: () => string;
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
}; };
@@ -38,7 +38,7 @@ export class SearchStore {
private readonly debounceMs: number; private readonly debounceMs: number;
private readonly filterDebounceMs: number; private readonly filterDebounceMs: number;
private readonly minQueryLength: number; private readonly minQueryLength: number;
private readonly filterParam: () => string; private readonly webFilterParam: () => string;
private readonly fetchImpl: typeof fetch; private readonly fetchImpl: typeof fetch;
private debounceTimer: ReturnType<typeof setTimeout> | null = null; private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private skipNextDebounce = false; private skipNextDebounce = false;
@@ -48,7 +48,7 @@ export class SearchStore {
this.debounceMs = opts.debounceMs ?? 300; this.debounceMs = opts.debounceMs ?? 300;
this.filterDebounceMs = opts.filterDebounceMs ?? 150; this.filterDebounceMs = opts.filterDebounceMs ?? 150;
this.minQueryLength = opts.minQueryLength ?? 4; this.minQueryLength = opts.minQueryLength ?? 4;
this.filterParam = opts.filterParam ?? (() => ''); this.webFilterParam = opts.webFilterParam ?? (() => '');
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a)); this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
} }
@@ -80,7 +80,7 @@ export class SearchStore {
this.webExhausted = false; this.webExhausted = false;
try { try {
const res = await this.fetchImpl( const res = await this.fetchImpl(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}` `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}`
); );
const body = (await res.json()) as { hits: SearchHit[] }; const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return; if (this.query.trim() !== q) return;
@@ -99,7 +99,7 @@ export class SearchStore {
this.webSearching = true; this.webSearching = true;
try { try {
const res = await this.fetchImpl( const res = await this.fetchImpl(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}` `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.webFilterParam()}`
); );
if (this.query.trim() !== q) return; if (this.query.trim() !== q) return;
if (!res.ok) { if (!res.ok) {
@@ -125,7 +125,7 @@ export class SearchStore {
try { try {
if (!this.localExhausted) { if (!this.localExhausted) {
const res = await this.fetchImpl( const res = await this.fetchImpl(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}` `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}`
); );
const body = (await res.json()) as { hits: SearchHit[] }; const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return; if (this.query.trim() !== q) return;
@@ -140,7 +140,7 @@ export class SearchStore {
if (wasEmpty) this.webSearching = true; if (wasEmpty) this.webSearching = true;
try { try {
const res = await this.fetchImpl( const res = await this.fetchImpl(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}` `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.webFilterParam()}`
); );
if (this.query.trim() !== q) return; if (this.query.trim() !== q) return;
if (!res.ok) { if (!res.ok) {

View File

@@ -0,0 +1,52 @@
type Snapshot = {
recipes: { recipe_id: number }[];
uncheckedCount: number;
};
export class ShoppingCartStore {
uncheckedCount = $state(0);
recipeIds = $state<Set<number>>(new Set());
loaded = $state(false);
private readonly fetchImpl: typeof fetch;
constructor(fetchImpl?: typeof fetch) {
this.fetchImpl = fetchImpl ?? ((...a) => fetch(...a));
}
async refresh(): Promise<void> {
try {
const res = await this.fetchImpl('/api/shopping-list');
if (!res.ok) return;
const body = (await res.json()) as Snapshot;
this.recipeIds = new Set(body.recipes.map((r) => r.recipe_id));
this.uncheckedCount = body.uncheckedCount;
this.loaded = true;
} catch {
// keep last known state on network error
}
}
async addRecipe(recipeId: number): Promise<void> {
const res = await this.fetchImpl('/api/shopping-list/recipe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ recipe_id: recipeId })
});
// Consume body to avoid leaking response, even if we ignore the payload.
await res.json().catch(() => null);
await this.refresh();
}
async removeRecipe(recipeId: number): Promise<void> {
const res = await this.fetchImpl(`/api/shopping-list/recipe/${recipeId}`, { method: 'DELETE' });
await res.json().catch(() => null);
await this.refresh();
}
isInCart(recipeId: number): boolean {
return this.recipeIds.has(recipeId);
}
}
export const shoppingCartStore = new ShoppingCartStore();

View File

@@ -134,14 +134,20 @@
</script> </script>
<div class="editor"> <div class="editor">
<section class="block"> {#if recipe.id !== null}
<h2>Bild</h2> <section class="block">
<ImageUploadBox <h2>Bild</h2>
recipeId={recipe.id!} <ImageUploadBox
imagePath={recipe.image_path} recipeId={recipe.id}
onchange={(p) => onimagechange?.(p)} imagePath={recipe.image_path}
/> onchange={(p) => onimagechange?.(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"> <div class="meta">
<label class="field"> <label class="field">
@@ -271,6 +277,15 @@
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
color: #2b6a3d; color: #2b6a3d;
} }
.block.info {
background: #f6faf7;
border: 1px dashed #cfd9d1;
}
.hint {
color: #666;
margin: 0;
font-size: 0.9rem;
}
.ing-list { .ing-list {
list-style: none; list-style: none;
padding: 0; padding: 0;

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { X, Minus, Plus } from 'lucide-svelte';
import type { ShoppingCartRecipe } from '$lib/server/shopping/repository';
let { recipe, onServingsChange, onRemove }: {
recipe: ShoppingCartRecipe;
onServingsChange: (id: number, servings: number) => void;
onRemove: (id: number) => void;
} = $props();
function dec() {
if (recipe.servings > 1) onServingsChange(recipe.recipe_id, recipe.servings - 1);
}
function inc() {
if (recipe.servings < 50) onServingsChange(recipe.recipe_id, recipe.servings + 1);
}
</script>
<div class="chip">
<a class="title" href={`/recipes/${recipe.recipe_id}`}>{recipe.title}</a>
<div class="controls">
<button aria-label="Portion weniger" onclick={dec} disabled={recipe.servings <= 1}>
<Minus size={16} />
</button>
<span class="val" aria-label="Portionen">{recipe.servings}p</span>
<button aria-label="Portion mehr" onclick={inc} disabled={recipe.servings >= 50}>
<Plus size={16} />
</button>
<button aria-label="Rezept aus Einkaufsliste entfernen" class="rm" onclick={() => onRemove(recipe.recipe_id)}>
<X size={16} />
</button>
</div>
</div>
<style>
.chip {
flex: 0 0 auto;
padding: 0.5rem 0.75rem;
background: white;
border: 1px solid #cfd9d1;
border-radius: 14px;
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 140px;
}
.title {
color: #2b6a3d;
font-weight: 600;
font-size: 0.92rem;
text-decoration: none;
line-height: 1.2;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls { display: flex; gap: 0.25rem; align-items: center; }
.controls button {
min-width: 32px;
min-height: 32px;
border-radius: 8px;
border: 1px solid #e4eae7;
background: white;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: #444;
}
.controls button:disabled { opacity: 0.4; cursor: not-allowed; }
.controls button.rm { margin-left: auto; }
.controls button.rm:hover { color: #c53030; border-color: #f1b4b4; background: #fdf3f3; }
.val { min-width: 32px; text-align: center; font-weight: 600; color: #444; }
</style>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import type { ShoppingListRow } from '$lib/server/shopping/repository';
import { formatQuantity } from '$lib/quantity-format';
let { row, onToggle }: {
row: ShoppingListRow;
onToggle: (row: ShoppingListRow, next: boolean) => void;
} = $props();
const qtyStr = $derived(formatQuantity(row.total_quantity));
const hasUnit = $derived(!!row.display_unit && row.display_unit.trim().length > 0);
</script>
<label class="row" class:checked={row.checked}>
<input
type="checkbox"
checked={row.checked === 1}
onchange={(e) => onToggle(row, (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="text">
<span class="name">
{#if qtyStr}
<span class="qty">{qtyStr}{hasUnit ? ` ${row.display_unit}` : ''}</span>
{/if}
{row.display_name}
</span>
<span class="src">aus {row.from_recipes}</span>
</span>
</label>
<style>
.row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem;
border: 1px solid #e4eae7;
border-radius: 10px;
background: white;
cursor: pointer;
min-height: 60px;
}
.row input {
width: 24px;
height: 24px;
margin-top: 0.1rem;
flex-shrink: 0;
accent-color: #2b6a3d;
}
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.name { font-size: 1rem; }
.qty { font-weight: 600; margin-right: 0.3rem; }
.src { color: #888; font-size: 0.82rem; }
.row.checked { background: #f6f8f7; }
.row.checked .name,
.row.checked .qty { text-decoration: line-through; color: #888; }
</style>

View File

@@ -0,0 +1,7 @@
export function formatQuantity(q: number | null): string {
if (q === null || q === undefined) return '';
return q.toLocaleString('de-DE', {
maximumFractionDigits: 2,
useGrouping: false
});
}

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,170 @@
import { GoogleGenerativeAI } from '@google/generative-ai';
import { env } from '$env/dynamic/private';
import {
RECIPE_EXTRACTION_SYSTEM_PROMPT,
RECIPE_EXTRACTION_USER_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 } },
{ text: RECIPE_EXTRACTION_USER_PROMPT }
];
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> {
let firstMsg: string | null = null;
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));
}
firstMsg = e instanceof Error ? e.message : String(e);
console.warn(`[gemini-client] first attempt failed, retrying: ${firstMsg}`);
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) {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
if (retryErr instanceof GeminiError) {
if (retryErr.code === 'AI_FAILED') {
throw new GeminiError(
'AI_FAILED',
`retry failed: ${retryMsg} (first: ${firstMsg})`
);
}
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',
`retry failed: ${retryMsg} (first: ${firstMsg})`
);
}
}
}

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,94 @@
import { z } from 'zod';
import { SchemaType } from '@google/generative-ai';
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein hochpräziser OCR-Experte für kulinarische Dokumente (Rezepte). Deine Aufgabe ist die Extraktion von Rezeptdaten (Titel, Zutaten, Zubereitungsschritte, Zeiten, Portionen) in valides JSON gemäß dem vorgegebenen Schema.
SPRACHE:
- Die Texte sind ausschließlich auf Deutsch. Nutze deutsches Sprachverständnis (Umlaute ä/ö/ü/ß, deutsche Zutatennamen, deutsche Maßeinheiten) als starken Prior bei der Rekonstruktion unklarer Zeichen. Gib die Ausgabe vollständig auf Deutsch zurück.
LOGIK-REGELN FÜR SCHWER LESBARE TEXTE:
- Handle als "Kontext-Detektiv": Wenn Zeichen unklar sind, nutze kulinarisches Wissen zur Rekonstruktion (z.B. "Pr-se" -> "Prise").
- Bei absoluter Unleserlichkeit eines Wortes: Nutze "[?]".
- Halluziniere keine fehlenden Werte: Wenn eine Mengenangabe komplett fehlt, setze 'quantity' auf null. Was nicht auf dem Bild steht, ist null (oder leeres Array).
FORMATIERUNGS-REGELN:
- Zutaten: quantity (Zahl) separat von unit (String). Brüche (½, ¼, 1 ½) strikt in Dezimalzahlen (0.5, 0.25, 1.5).
- Einheiten: Normalisiere auf (g, ml, l, kg, EL, TL, Stück, Prise, Msp).
- Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt.
- Zeit: Alle Angaben strikt in Minuten (Integer). "1 Stunde" = 60.
- Rauschen ignorieren: Keine Werbung, Einleitungstexte oder Bildunterschriften extrahieren.
STRIKTE ANWEISUNG: Gib ausschließlich das rohe JSON-Objekt gemäß Schema zurück. Kein Markdown-Code-Block, kein Einleitungstext, keine Prosa.`;
export const RECIPE_EXTRACTION_USER_PROMPT =
'Analysiere dieses Bild hochauflösend. Extrahiere alle rezeptrelevanten Informationen gemäß deiner System-Instruktion. Achte besonders auf schwache Handschriften oder verblassten Text und stelle sicher, dass die Zuordnung von Menge zu Zutat logisch korrekt ist.';
// 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,18 @@
-- Einkaufsliste: haushaltsweit geteilt. shopping_cart_recipe haelt die
-- Rezepte im Wagen (inkl. gewuenschter Portionsgroesse), shopping_cart_check
-- die abgehakten aggregierten Zutaten-Zeilen. Aggregation wird bei jedem
-- Read aus shopping_cart_recipe JOIN ingredient derived — nichts
-- materialisiert, damit Rezept-Edits live durchschlagen.
CREATE TABLE shopping_cart_recipe (
recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
servings INTEGER NOT NULL CHECK (servings > 0),
added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE shopping_cart_check (
name_key TEXT NOT NULL,
unit_key TEXT NOT NULL,
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (name_key, unit_key)
);

View File

@@ -0,0 +1,10 @@
-- Merkt je Profil, wann ein Rezept zuletzt angesehen wurde.
-- Dient als Basis fuer "Zuletzt gesehen"-Sortierung auf der Startseite.
CREATE TABLE recipe_view (
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
last_viewed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (profile_id, recipe_id)
);
CREATE INDEX idx_recipe_view_recent
ON recipe_view (profile_id, last_viewed_at DESC);

View File

@@ -0,0 +1,14 @@
-- Konsolidierung: unit_key in shopping_cart_check wird zum Family-Key, damit
-- Abhaks stabil bleiben wenn Display-Unit zwischen g und kg wechselt.
-- g/kg → 'weight', ml/l → 'volume', Rest bleibt unveraendert.
UPDATE shopping_cart_check SET unit_key = 'weight' WHERE LOWER(TRIM(unit_key)) IN ('g', 'kg');
UPDATE shopping_cart_check SET unit_key = 'volume' WHERE LOWER(TRIM(unit_key)) IN ('ml', 'l');
-- Nach Relabeling koennen Duplikate entstehen (zwei Zeilen mit 'weight' pro
-- name_key). Juengsten Eintrag behalten.
DELETE FROM shopping_cart_check
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM shopping_cart_check
GROUP BY name_key, unit_key
);

View File

@@ -30,15 +30,12 @@ export function searchLocal(
db: Database.Database, db: Database.Database,
query: string, query: string,
limit = 30, limit = 30,
offset = 0, offset = 0
domains: string[] = []
): SearchHit[] { ): SearchHit[] {
const fts = buildFtsQuery(query); const fts = buildFtsQuery(query);
if (!fts) return []; if (!fts) return [];
// bm25: lower is better. Use weights: title > tags > ingredients > description // bm25: lower is better. Use weights: title > tags > ingredients > description
const hasFilter = domains.length > 0;
const placeholders = hasFilter ? domains.map(() => '?').join(',') : '';
const sql = `SELECT r.id, const sql = `SELECT r.id,
r.title, r.title,
r.description, r.description,
@@ -49,13 +46,9 @@ export function searchLocal(
FROM recipe r FROM recipe r
JOIN recipe_fts f ON f.rowid = r.id JOIN recipe_fts f ON f.rowid = r.id
WHERE recipe_fts MATCH ? WHERE recipe_fts MATCH ?
${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''}
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0) ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
LIMIT ? OFFSET ?`; LIMIT ? OFFSET ?`;
const params = hasFilter return db.prepare(sql).all(fts, limit, offset) as SearchHit[];
? [fts, ...domains, limit, offset]
: [fts, limit, offset];
return db.prepare(sql).all(...params) as SearchHit[];
} }
export function listRecentRecipes( export function listRecentRecipes(
@@ -95,18 +88,44 @@ export function listAllRecipes(db: Database.Database): SearchHit[] {
.all() as SearchHit[]; .all() as SearchHit[];
} }
export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created'; export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
export function listAllRecipesPaginated( export function listAllRecipesPaginated(
db: Database.Database, db: Database.Database,
sort: AllRecipesSort, sort: AllRecipesSort,
limit: number, limit: number,
offset: number offset: number,
profileId: number | null = null
): SearchHit[] { ): SearchHit[] {
// 'viewed' branch needs a JOIN against recipe_view — diverges from the
// simpler ORDER-BY-only path. We keep it in a separate prepare for
// clarity. Without profileId, fall back to alphabetical so the
// sort-chip still produces a sensible list.
if (sort === 'viewed' && profileId !== null) {
return db
.prepare(
`SELECT r.id,
r.title,
r.description,
r.image_path,
r.source_domain,
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r
LEFT JOIN recipe_view v
ON v.recipe_id = r.id AND v.profile_id = ?
ORDER BY CASE WHEN v.last_viewed_at IS NULL THEN 1 ELSE 0 END,
v.last_viewed_at DESC,
r.title COLLATE NOCASE ASC
LIMIT ? OFFSET ?`
)
.all(profileId, limit, offset) as SearchHit[];
}
// NULLS-last-Emulation per CASE-Expression — SQLite unterstützt NULLS LAST // NULLS-last-Emulation per CASE-Expression — SQLite unterstützt NULLS LAST
// zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und // zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und
// CASE ist überall zuverlässig. // CASE ist überall zuverlässig.
const orderBy: Record<AllRecipesSort, string> = { const orderBy: Record<Exclude<AllRecipesSort, 'viewed'>, string> = {
name: 'r.title COLLATE NOCASE ASC', name: 'r.title COLLATE NOCASE ASC',
rating: rating:
'CASE WHEN (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' + 'CASE WHEN (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
@@ -116,6 +135,8 @@ export function listAllRecipesPaginated(
'(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC', '(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
created: 'r.created_at DESC, r.id DESC' created: 'r.created_at DESC, r.id DESC'
}; };
// Without profile, 'viewed' degrades to alphabetical.
const effectiveSort = sort === 'viewed' ? 'name' : sort;
return db return db
.prepare( .prepare(
`SELECT r.id, `SELECT r.id,
@@ -126,7 +147,7 @@ export function listAllRecipesPaginated(
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars, (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r FROM recipe r
ORDER BY ${orderBy[sort]} ORDER BY ${orderBy[effectiveSort]}
LIMIT ? OFFSET ?` LIMIT ? OFFSET ?`
) )
.all(limit, offset) as SearchHit[]; .all(limit, offset) as SearchHit[];

View File

@@ -0,0 +1,37 @@
import type Database from 'better-sqlite3';
export function recordView(
db: Database.Database,
profileId: number,
recipeId: number
): void {
// ON CONFLICT DO UPDATE bumps only the timestamp field — avoids the
// DELETE+INSERT that INSERT OR REPLACE performs under the hood, which would
// silently cascade-delete any future FK-referencing children.
db.prepare(
`INSERT INTO recipe_view (profile_id, recipe_id)
VALUES (?, ?)
ON CONFLICT(profile_id, recipe_id) DO UPDATE
SET last_viewed_at = CURRENT_TIMESTAMP`
).run(profileId, recipeId);
}
export type ViewRow = {
profile_id: number;
recipe_id: number;
last_viewed_at: string;
};
export function listViews(
db: Database.Database,
profileId: number
): ViewRow[] {
return db
.prepare(
`SELECT profile_id, recipe_id, last_viewed_at
FROM recipe_view
WHERE profile_id = ?
ORDER BY last_viewed_at DESC`
)
.all(profileId) as ViewRow[];
}

View File

@@ -0,0 +1,262 @@
import type Database from 'better-sqlite3';
import { consolidate, unitFamily } from '../unit-consolidation';
// Fallback when a recipe has no servings_default set — matches the default
// used by RecipeEditor's "new recipe" template.
const DEFAULT_SERVINGS = 4;
export type ShoppingCartRecipe = {
recipe_id: number;
title: string;
image_path: string | null;
servings: number;
servings_default: number;
};
export type ShoppingListRow = {
name_key: string;
unit_key: string;
display_name: string;
display_unit: string | null;
total_quantity: number | null;
from_recipes: string;
checked: 0 | 1;
};
export type ShoppingListSnapshot = {
recipes: ShoppingCartRecipe[];
rows: ShoppingListRow[];
uncheckedCount: number;
};
export function addRecipeToCart(
db: Database.Database,
recipeId: number,
profileId: number | null,
servings?: number
): void {
const row = db
.prepare('SELECT servings_default FROM recipe WHERE id = ?')
.get(recipeId) as { servings_default: number | null } | undefined;
const resolved = servings ?? row?.servings_default ?? DEFAULT_SERVINGS;
// ON CONFLICT updates only servings — added_by_profile_id stays with the
// first profile that added the recipe (household cart, audit trail).
db.prepare(
`INSERT INTO shopping_cart_recipe (recipe_id, servings, added_by_profile_id)
VALUES (?, ?, ?)
ON CONFLICT(recipe_id) DO UPDATE SET servings = excluded.servings`
).run(recipeId, resolved, profileId);
}
export function removeRecipeFromCart(
db: Database.Database,
recipeId: number
): void {
db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(recipeId);
}
export function setCartServings(
db: Database.Database,
recipeId: number,
servings: number
): void {
if (!Number.isInteger(servings) || servings <= 0) {
throw new Error(`Invalid servings: ${servings}`);
}
db.prepare(
'UPDATE shopping_cart_recipe SET servings = ? WHERE recipe_id = ?'
).run(servings, recipeId);
}
export function listShoppingList(
db: Database.Database
): ShoppingListSnapshot {
const recipes = db
.prepare(
`SELECT cr.recipe_id, r.title, r.image_path, cr.servings,
COALESCE(r.servings_default, cr.servings) AS servings_default
FROM shopping_cart_recipe cr
JOIN recipe r ON r.id = cr.recipe_id
ORDER BY cr.added_at ASC`
)
.all() as ShoppingCartRecipe[];
// SQL aggregates per (name, raw-unit). Family-grouping + consolidation is
// done in TypeScript so SQL stays readable and the logic is unit-testable.
type RawRow = {
name_key: string;
unit_key: string;
display_name: string;
display_unit: string | null;
total_quantity: number | null;
from_recipes: string;
};
const raw = db
.prepare(
`SELECT
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key,
MIN(i.name) AS display_name,
MIN(i.unit) AS display_unit,
SUM(i.quantity * cr.servings * 1.0 / NULLIF(COALESCE(r.servings_default, cr.servings), 0)) AS total_quantity,
GROUP_CONCAT(DISTINCT r.title) AS from_recipes
FROM shopping_cart_recipe cr
JOIN recipe r ON r.id = cr.recipe_id
JOIN ingredient i ON i.recipe_id = r.id
GROUP BY name_key, unit_key`
)
.all() as RawRow[];
// Load all checked keys up front
const checkedSet = new Set(
(
db
.prepare('SELECT name_key, unit_key FROM shopping_cart_check')
.all() as { name_key: string; unit_key: string }[]
).map((c) => `${c.name_key}|${c.unit_key}`)
);
// Group by (name_key, unitFamily(unit_key))
const grouped = new Map<string, RawRow[]>();
for (const r of raw) {
const familyKey = unitFamily(r.unit_key);
const key = `${r.name_key}|${familyKey}`;
const arr = grouped.get(key) ?? [];
arr.push(r);
grouped.set(key, arr);
}
const rows: ShoppingListRow[] = [];
for (const members of grouped.values()) {
const nameKey = members[0].name_key;
const familyKey = unitFamily(members[0].unit_key);
const consolidated = consolidate(
members.map((m) => ({ quantity: m.total_quantity, unit: m.display_unit }))
);
const displayName = members[0].display_name;
const allRecipes = new Set<string>();
for (const m of members) {
for (const t of m.from_recipes.split(',')) allRecipes.add(t);
}
rows.push({
name_key: nameKey,
unit_key: familyKey,
display_name: displayName,
display_unit: consolidated.unit,
total_quantity: consolidated.quantity,
from_recipes: [...allRecipes].join(','),
checked: checkedSet.has(`${nameKey}|${familyKey}`) ? 1 : 0
});
}
// Sort: unchecked first, then alphabetically by display_name
rows.sort((a, b) => {
if (a.checked !== b.checked) return a.checked - b.checked;
return a.display_name.localeCompare(b.display_name, 'de', { sensitivity: 'base' });
});
const uncheckedCount = rows.reduce((n, r) => n + (r.checked ? 0 : 1), 0);
return { recipes, rows, uncheckedCount };
}
export function toggleCheck(
db: Database.Database,
nameKey: string,
unitKey: string,
checked: boolean
): void {
if (checked) {
db.prepare(
`INSERT INTO shopping_cart_check (name_key, unit_key)
VALUES (?, ?)
ON CONFLICT(name_key, unit_key) DO NOTHING`
).run(nameKey, unitKey);
} else {
db.prepare(
'DELETE FROM shopping_cart_check WHERE name_key = ? AND unit_key = ?'
).run(nameKey, unitKey);
}
}
export function clearCheckedItems(db: Database.Database): void {
const tx = db.transaction(() => {
// Rohe (name, unit)-Zeilen holen, checked-Status per Family-Key-Lookup
// in JS entscheiden. Rezepte mit ALLEN Zeilen abgehakt werden raus.
const allRowsRaw = db
.prepare(
`SELECT
cr.recipe_id,
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key
FROM shopping_cart_recipe cr
JOIN ingredient i ON i.recipe_id = cr.recipe_id`
)
.all() as { recipe_id: number; name_key: string; unit_key: string }[];
const checkSet = new Set(
(
db
.prepare('SELECT name_key, unit_key FROM shopping_cart_check')
.all() as { name_key: string; unit_key: string }[]
).map((c) => `${c.name_key}|${c.unit_key}`)
);
const allRows = allRowsRaw.map((r) => ({
recipe_id: r.recipe_id,
name_key: r.name_key,
unit_key: r.unit_key,
checked: checkSet.has(`${r.name_key}|${unitFamily(r.unit_key)}`) ? (1 as const) : (0 as const)
}));
const perRecipe = new Map<number, { total: number; checked: number }>();
for (const r of allRows) {
const e = perRecipe.get(r.recipe_id) ?? { total: 0, checked: 0 };
e.total += 1;
e.checked += r.checked;
perRecipe.set(r.recipe_id, e);
}
const toRemove: number[] = [];
for (const [id, e] of perRecipe) {
if (e.total > 0 && e.total === e.checked) toRemove.push(id);
}
for (const id of toRemove) {
db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(id);
}
// Orphan-Checks raeumen: Active-Keys nach (name_key, unitFamily(raw-unit))
// bauen, damit Checks mit family-key korrekt gematcht werden.
const activeRaw = db
.prepare(
`SELECT DISTINCT
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key
FROM shopping_cart_recipe cr
JOIN ingredient i ON i.recipe_id = cr.recipe_id`
)
.all() as { name_key: string; unit_key: string }[];
const activeSet = new Set(
activeRaw.map((k) => `${k.name_key}|${unitFamily(k.unit_key)}`)
);
const allChecks = db
.prepare('SELECT name_key, unit_key FROM shopping_cart_check')
.all() as { name_key: string; unit_key: string }[];
const del = db.prepare(
'DELETE FROM shopping_cart_check WHERE name_key = ? AND unit_key = ?'
);
for (const c of allChecks) {
if (!activeSet.has(`${c.name_key}|${c.unit_key}`)) {
del.run(c.name_key, c.unit_key);
}
}
});
tx();
}
export function clearCart(db: Database.Database): void {
const tx = db.transaction(() => {
db.prepare('DELETE FROM shopping_cart_recipe').run();
db.prepare('DELETE FROM shopping_cart_check').run();
});
tx();
}

View File

@@ -0,0 +1,66 @@
export type UnitFamily = 'weight' | 'volume' | string;
const WEIGHT_UNITS = new Set(['g', 'kg']);
const VOLUME_UNITS = new Set(['ml', 'l']);
export function unitFamily(unit: string | null | undefined): UnitFamily {
const u = (unit ?? '').trim().toLowerCase();
if (WEIGHT_UNITS.has(u)) return 'weight';
if (VOLUME_UNITS.has(u)) return 'volume';
return u;
}
export interface QuantityInUnit {
quantity: number | null;
unit: string | null;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* Konsolidiert mehrere {quantity, unit}-Eintraege derselben Unit-Family
* zu einer gemeinsamen Menge + Display-Unit.
*
* - Gewicht (g, kg): summiert in g, promoted bei >=1000 g auf kg.
* - Volumen (ml, l): summiert in ml, promoted bei >=1000 ml auf l.
* - Andere: summiert quantity ohne Umrechnung, Display-Unit vom ersten
* Eintrag.
*
* quantity=null wird als 0 behandelt. Wenn ALLE quantities null sind,
* ist die Gesamtmenge ebenfalls null.
*/
export function consolidate(rows: QuantityInUnit[]): QuantityInUnit {
if (rows.length === 0) return { quantity: null, unit: null };
const family = unitFamily(rows[0].unit);
const firstUnit = rows[0].unit;
const allNull = rows.every((r) => r.quantity === null);
if (family === 'weight') {
if (allNull) return { quantity: null, unit: firstUnit };
const grams = rows.reduce((sum, r) => {
const q = r.quantity ?? 0;
return sum + (r.unit?.toLowerCase().trim() === 'kg' ? q * 1000 : q);
}, 0);
if (grams >= 1000) return { quantity: round2(grams / 1000), unit: 'kg' };
return { quantity: round2(grams), unit: 'g' };
}
if (family === 'volume') {
if (allNull) return { quantity: null, unit: firstUnit };
const ml = rows.reduce((sum, r) => {
const q = r.quantity ?? 0;
return sum + (r.unit?.toLowerCase().trim() === 'l' ? q * 1000 : q);
}, 0);
if (ml >= 1000) return { quantity: round2(ml / 1000), unit: 'l' };
return { quantity: round2(ml), unit: 'ml' };
}
// Non-family: summiere quantity direkt
if (allNull) return { quantity: null, unit: firstUnit };
const sum = rows.reduce((acc, r) => acc + (r.quantity ?? 0), 0);
return { quantity: round2(sum), unit: firstUnit };
}

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

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

@@ -1,10 +1,20 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto, afterNavigate } from '$app/navigation'; import { goto, afterNavigate, beforeNavigate } from '$app/navigation';
import { Settings, CookingPot, Utensils, Menu, BookOpen, ArrowLeft } from 'lucide-svelte'; import {
Settings,
CookingPot,
Utensils,
Menu,
BookOpen,
ArrowLeft,
Camera,
ShoppingCart
} from 'lucide-svelte';
import { profileStore } from '$lib/client/profile.svelte'; import { profileStore } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte'; import { wishlistStore } from '$lib/client/wishlist.svelte';
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
import { pwaStore } from '$lib/client/pwa.svelte'; import { pwaStore } from '$lib/client/pwa.svelte';
import { searchFilterStore } from '$lib/client/search-filter.svelte'; import { searchFilterStore } from '$lib/client/search-filter.svelte';
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte'; import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
@@ -18,12 +28,13 @@
import { installPrompt } from '$lib/client/install-prompt.svelte'; import { installPrompt } from '$lib/client/install-prompt.svelte';
import { registerServiceWorker } from '$lib/client/sw-register'; import { registerServiceWorker } from '$lib/client/sw-register';
import { SearchStore } from '$lib/client/search.svelte'; import { SearchStore } from '$lib/client/search.svelte';
import { recordScroll, restoreScroll } from '$lib/client/scroll-restore';
let { children } = $props(); let { data, children } = $props();
const navStore = new SearchStore({ const navStore = new SearchStore({
pageSize: 30, pageSize: 30,
filterParam: () => { webFilterParam: () => {
const p = searchFilterStore.queryParam; const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : ''; return p ? `&domains=${encodeURIComponent(p)}` : '';
} }
@@ -79,7 +90,19 @@
navStore.reset(); navStore.reset();
} }
afterNavigate(() => { function goBack() {
if (typeof history !== 'undefined' && history.length > 1) {
history.back();
} else {
void goto('/');
}
}
beforeNavigate((nav) => {
recordScroll(nav.from?.url);
});
afterNavigate((nav) => {
navStore.reset(); navStore.reset();
navOpen = false; navOpen = false;
menuOpen = false; menuOpen = false;
@@ -88,11 +111,14 @@
// auf einem anderen Gerät oder in einem anderen Tab etwas geändert // auf einem anderen Gerät oder in einem anderen Tab etwas geändert
// wurde. // wurde.
void wishlistStore.refresh(); void wishlistStore.refresh();
void shoppingCartStore.refresh();
restoreScroll(nav.type, nav.to?.url);
}); });
onMount(() => { onMount(() => {
profileStore.load(); profileStore.load();
void wishlistStore.refresh(); void wishlistStore.refresh();
void shoppingCartStore.refresh();
void searchFilterStore.load(); void searchFilterStore.load();
void pwaStore.init(); void pwaStore.init();
network.init(); network.init();
@@ -115,11 +141,14 @@
<header class="bar"> <header class="bar">
<div class="bar-inner"> <div class="bar-inner">
{#if $page.url.pathname === '/'} {#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} {:else}
<a href="/" class="home-back" aria-label="Zurück zur Startseite"> <button type="button" class="home-back" aria-label="Zurück" onclick={goBack}>
<ArrowLeft size={22} strokeWidth={2} /> <ArrowLeft size={22} strokeWidth={2} />
</a> </button>
{/if} {/if}
{#if showHeaderSearch} {#if showHeaderSearch}
<div class="nav-search-wrap" bind:this={navContainer}> <div class="nav-search-wrap" bind:this={navContainer}>
@@ -229,6 +258,22 @@
</div> </div>
{/if} {/if}
<div class="bar-right"> <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 <a
href="/wishlist" href="/wishlist"
class="nav-link wishlist-link" class="nav-link wishlist-link"
@@ -241,6 +286,16 @@
<span class="badge">{wishlistStore.count}</span> <span class="badge">{wishlistStore.count}</span>
{/if} {/if}
</a> </a>
{#if shoppingCartStore.uncheckedCount > 0}
<a
href="/shopping-list"
class="nav-link shopping-link"
aria-label={`Einkaufsliste (${shoppingCartStore.uncheckedCount})`}
>
<ShoppingCart size={20} strokeWidth={2} />
<span class="badge">{shoppingCartStore.uncheckedCount}</span>
</a>
{/if}
<div class="menu-wrap" bind:this={menuContainer}> <div class="menu-wrap" bind:this={menuContainer}>
<button <button
class="nav-link" class="nav-link"
@@ -307,6 +362,13 @@
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
position: relative; position: relative;
} }
.brand-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1;
flex-shrink: 0;
}
.brand { .brand {
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
@@ -314,16 +376,28 @@
color: #2b6a3d; color: #2b6a3d;
flex-shrink: 0; flex-shrink: 0;
} }
.version {
margin-top: 2px;
font-size: 0.65rem;
color: #9aa8a0;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.home-back { .home-back {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 0;
border-radius: var(--pill-radius); border-radius: var(--pill-radius);
background: transparent;
color: #2b6a3d; color: #2b6a3d;
text-decoration: none; text-decoration: none;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
font: inherit;
padding: 0;
} }
.home-back:hover { .home-back:hover {
background: #f4f8f5; background: #f4f8f5;
@@ -520,6 +594,11 @@
.nav-link:hover { .nav-link:hover {
background: #f4f8f5; background: #f4f8f5;
} }
.nav-link.disabled {
color: #999;
pointer-events: none;
cursor: not-allowed;
}
.badge { .badge {
position: absolute; position: absolute;
top: -2px; top: -2px;
@@ -544,7 +623,7 @@
} }
@media (max-width: 520px) { @media (max-width: 520px) {
/* App-Icon auf engen Screens komplett aus — die Suche bekommt den Platz. */ /* App-Icon auf engen Screens komplett aus — die Suche bekommt den Platz. */
.brand { .brand-stack {
display: none; display: none;
} }
.nav-link { .nav-link {

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { CookingPot, X } from 'lucide-svelte'; import { CookingPot, X, ChevronDown } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { Snapshot } from './$types'; import type { Snapshot } from './$types';
import type { SearchHit } from '$lib/server/recipes/search-local'; import type { SearchHit } from '$lib/server/recipes/search-local';
import { randomQuote } from '$lib/quotes'; import { randomQuote } from '$lib/quotes';
@@ -16,7 +17,7 @@
const store = new SearchStore({ const store = new SearchStore({
pageSize: LOCAL_PAGE, pageSize: LOCAL_PAGE,
filterParam: () => { webFilterParam: () => {
const p = searchFilterStore.queryParam; const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : ''; return p ? `&domains=${encodeURIComponent(p)}` : '';
} }
@@ -27,13 +28,20 @@
let favorites = $state<SearchHit[]>([]); let favorites = $state<SearchHit[]>([]);
const ALL_PAGE = 10; const ALL_PAGE = 10;
type AllSort = 'name' | 'rating' | 'cooked' | 'created'; type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
const ALL_SORTS: { value: AllSort; label: string }[] = [ const ALL_SORTS: { value: AllSort; label: string }[] = [
{ value: 'name', label: 'Name' }, { value: 'name', label: 'Name' },
{ value: 'rating', label: 'Bewertung' }, { value: 'rating', label: 'Bewertung' },
{ value: 'cooked', label: 'Zuletzt gekocht' }, { value: 'cooked', label: 'Zuletzt gekocht' },
{ value: 'created', label: 'Hinzugefügt' } { value: 'created', label: 'Hinzugefügt' },
{ value: 'viewed', label: 'Zuletzt angesehen' }
]; ];
function buildAllUrl(sort: AllSort, limit: number, offset: number): string {
const profileId = profileStore.active?.id;
const profilePart = profileId ? `&profile_id=${profileId}` : '';
return `/api/recipes/all?sort=${sort}&limit=${limit}&offset=${offset}${profilePart}`;
}
let allRecipes = $state<SearchHit[]>([]); let allRecipes = $state<SearchHit[]>([]);
let allSort = $state<AllSort>('name'); let allSort = $state<AllSort>('name');
let allExhausted = $state(false); let allExhausted = $state(false);
@@ -42,11 +50,68 @@
let allChips: HTMLElement | undefined = $state(); let allChips: HTMLElement | undefined = $state();
let allObserver: IntersectionObserver | null = null; let allObserver: IntersectionObserver | null = null;
export const snapshot: Snapshot<SearchSnapshot> = { type CollapseKey = 'favorites' | 'recent';
capture: () => store.captureSnapshot(), const COLLAPSE_STORAGE_KEY = 'kochwas.collapsed.sections';
restore: (s) => store.restoreSnapshot(s) let collapsed = $state<Record<CollapseKey, boolean>>({
favorites: false,
recent: false
});
function toggleCollapsed(key: CollapseKey) {
collapsed[key] = !collapsed[key];
if (typeof localStorage !== 'undefined') {
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapsed));
}
}
// Snapshot persists across history navigation. We capture not only the
// search store, but also the pagination depth ("user had loaded 60
// recipes via infinite scroll") so on back-nav we can re-hydrate the
// full list in one fetch — otherwise the document is too short and
// scroll-restore can't reach the saved Y position.
//
// SvelteKit calls snapshot.restore AFTER onMount (post-mount tick),
// so a flag-based handoff to onMount won't fire — we trigger
// rehydrateAll directly from restore. onMount still calls
// loadAllMore() for the fresh-mount case; if restore lands first,
// allLoading guards the duplicate fetch, otherwise rehydrateAll's
// larger result simply overwrites loadAllMore's initial 10 items.
type HomeSnapshot = SearchSnapshot & {
allLoaded: number;
allSort: AllSort;
allExhausted: boolean;
}; };
export const snapshot: Snapshot<HomeSnapshot> = {
capture: () => ({
...store.captureSnapshot(),
allLoaded: allRecipes.length,
allSort,
allExhausted
}),
restore: (s) => {
store.restoreSnapshot(s);
if (s.allLoaded > 0) {
allSort = s.allSort;
void rehydrateAll(s.allSort, s.allLoaded, s.allExhausted);
}
}
};
async function rehydrateAll(sort: AllSort, count: number, exhausted: boolean) {
allLoading = true;
try {
const res = await fetch(buildAllUrl(sort, count, 0));
if (!res.ok) return;
const body = await res.json();
const hits = body.hits as SearchHit[];
allRecipes = hits;
allExhausted = exhausted || hits.length < count;
} finally {
allLoading = false;
}
}
async function loadRecent() { async function loadRecent() {
const res = await fetch('/api/recipes/search'); const res = await fetch('/api/recipes/search');
const body = await res.json(); const body = await res.json();
@@ -57,9 +122,7 @@
if (allLoading || allExhausted) return; if (allLoading || allExhausted) return;
allLoading = true; allLoading = true;
try { try {
const res = await fetch( const res = await fetch(buildAllUrl(allSort, ALL_PAGE, allRecipes.length));
`/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}`
);
if (!res.ok) return; if (!res.ok) return;
const body = await res.json(); const body = await res.json();
const more = body.hits as SearchHit[]; const more = body.hits as SearchHit[];
@@ -83,9 +146,7 @@
const chipsBefore = allChips?.getBoundingClientRect().top ?? 0; const chipsBefore = allChips?.getBoundingClientRect().top ?? 0;
allLoading = true; allLoading = true;
try { try {
const res = await fetch( const res = await fetch(buildAllUrl(next, ALL_PAGE, 0));
`/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
);
if (!res.ok) return; if (!res.ok) return;
const body = await res.json(); const body = await res.json();
const hits = body.hits as SearchHit[]; const hits = body.hits as SearchHit[];
@@ -121,10 +182,24 @@
void loadRecent(); void loadRecent();
void searchFilterStore.load(); void searchFilterStore.load();
const saved = localStorage.getItem('kochwas.allSort'); const saved = localStorage.getItem('kochwas.allSort');
if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) { if (saved && ['name', 'rating', 'cooked', 'created', 'viewed'].includes(saved)) {
allSort = saved as AllSort; allSort = saved as AllSort;
} }
// Fresh-mount: kick off the initial 10. On back-nav, snapshot.restore
// also fires rehydrateAll(60); if it lands first, allLoading guards
// this; if loadAllMore lands first, rehydrateAll's larger result
// simply overwrites allRecipes once it resolves.
void loadAllMore(); void loadAllMore();
const rawCollapsed = localStorage.getItem(COLLAPSE_STORAGE_KEY);
if (rawCollapsed) {
try {
const parsed = JSON.parse(rawCollapsed) as Partial<Record<CollapseKey, boolean>>;
if (typeof parsed.favorites === 'boolean') collapsed.favorites = parsed.favorites;
if (typeof parsed.recent === 'boolean') collapsed.recent = parsed.recent;
} catch {
// Corrupt JSON — keep defaults (both open).
}
}
}); });
// IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen. // IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen.
@@ -156,6 +231,37 @@
store.reSearch(); store.reSearch();
}); });
// 'viewed' sort depends on the active profile. When the user switches
// profiles, refetch with the new profile_id so the list reflects what
// the *current* profile has viewed. Other sorts are profile-agnostic
// and don't need this.
//
// Only `profileStore.active` must be a tracked dep. `allSort` /
// `allLoading` are read inside untrack: otherwise the `allLoading = false`
// write in the fetch-finally would re-trigger the effect and start the
// next fetch → endless loop.
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
profileStore.active;
untrack(() => {
if (allSort !== 'viewed') return;
if (allLoading) return;
void (async () => {
allLoading = true;
try {
const res = await fetch(buildAllUrl('viewed', ALL_PAGE, 0));
if (!res.ok) return;
const body = await res.json();
const hits = body.hits as SearchHit[];
allRecipes = hits;
allExhausted = hits.length < ALL_PAGE;
} finally {
allLoading = false;
}
})();
});
});
// Sync current query back into the URL as ?q=... via replaceState, // Sync current query back into the URL as ?q=... via replaceState,
// without spamming the history stack. Pushing a new entry happens only // without spamming the history stack. Pushing a new entry happens only
// when the user clicks a result or otherwise navigates away. // when the user clicks a result or otherwise navigates away.
@@ -299,57 +405,91 @@
{:else} {:else}
{#if profileStore.active && favorites.length > 0} {#if profileStore.active && favorites.length > 0}
<section class="listing"> <section class="listing">
<h2>Deine Favoriten</h2> <button
<ul class="cards"> type="button"
{#each favorites as r (r.id)} class="section-head"
<li class="card-wrap"> onclick={() => toggleCollapsed('favorites')}
<a href={`/recipes/${r.id}`} class="card"> aria-expanded={!collapsed.favorites}
{#if r.image_path} >
<img src={`/images/${r.image_path}`} alt="" loading="lazy" /> <ChevronDown
{:else} size={18}
<div class="placeholder"><CookingPot size={36} /></div> strokeWidth={2.2}
{/if} class={collapsed.favorites ? 'chev rotated' : 'chev'}
<div class="card-body"> />
<div class="title">{r.title}</div> <h2>Deine Favoriten</h2>
{#if r.source_domain} <span class="count">{favorites.length}</span>
<div class="domain">{r.source_domain}</div> </button>
{/if} {#if !collapsed.favorites}
</div> <div transition:slide={{ duration: 180 }}>
</a> <ul class="cards">
</li> {#each favorites as r (r.id)}
{/each} <li class="card-wrap">
</ul> <a href={`/recipes/${r.id}`} class="card">
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={36} /></div>
{/if}
<div class="card-body">
<div class="title">{r.title}</div>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
</div>
{/if}
</section> </section>
{/if} {/if}
{#if recent.length > 0} {#if recent.length > 0}
<section class="listing"> <section class="listing">
<h2>Zuletzt hinzugefügt</h2> <button
<ul class="cards"> type="button"
{#each recent as r (r.id)} class="section-head"
<li class="card-wrap"> onclick={() => toggleCollapsed('recent')}
<a href={`/recipes/${r.id}`} class="card"> aria-expanded={!collapsed.recent}
{#if r.image_path} >
<img src={`/images/${r.image_path}`} alt="" loading="lazy" /> <ChevronDown
{:else} size={18}
<div class="placeholder"><CookingPot size={36} /></div> strokeWidth={2.2}
{/if} class={collapsed.recent ? 'chev rotated' : 'chev'}
<div class="card-body"> />
<div class="title">{r.title}</div> <h2>Zuletzt hinzugefügt</h2>
{#if r.source_domain} <span class="count">{recent.length}</span>
<div class="domain">{r.source_domain}</div> </button>
{/if} {#if !collapsed.recent}
</div> <div transition:slide={{ duration: 180 }}>
</a> <ul class="cards">
<button {#each recent as r (r.id)}
class="dismiss" <li class="card-wrap">
aria-label="Aus Zuletzt-hinzugefügt entfernen" <a href={`/recipes/${r.id}`} class="card">
onclick={(e) => dismissFromRecent(r.id, e)} {#if r.image_path}
> <img src={`/images/${r.image_path}`} alt="" loading="lazy" />
<X size={16} strokeWidth={2.5} /> {:else}
</button> <div class="placeholder"><CookingPot size={36} /></div>
</li> {/if}
{/each} <div class="card-body">
</ul> <div class="title">{r.title}</div>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
<button
class="dismiss"
aria-label="Aus Zuletzt-hinzugefügt entfernen"
onclick={(e) => dismissFromRecent(r.id, e)}
>
<X size={16} strokeWidth={2.5} />
</button>
</li>
{/each}
</ul>
</div>
{/if}
</section> </section>
{/if} {/if}
<section class="listing"> <section class="listing">
@@ -469,6 +609,49 @@
color: #444; color: #444;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
.section-head {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4rem 0.25rem;
background: transparent;
border: 0;
border-radius: 8px;
text-align: left;
cursor: pointer;
font-family: inherit;
color: inherit;
min-height: 44px;
margin-bottom: 0.4rem;
}
.section-head:hover {
background: #f4f8f5;
}
.section-head:focus-visible {
outline: 2px solid #2b6a3d;
outline-offset: 2px;
}
.section-head h2 {
margin: 0;
font-size: 1.05rem;
color: #444;
font-weight: 600;
}
.section-head .count {
margin-left: auto;
color: #888;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.section-head :global(.chev) {
color: #2b6a3d;
flex-shrink: 0;
transition: transform 180ms;
}
.section-head :global(.chev.rotated) {
transform: rotate(-90deg);
}
.listing-head { .listing-head {
display: flex; display: flex;
align-items: center; align-items: center;

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

@@ -0,0 +1,27 @@
import type { RequestHandler } from './$types';
import { z } from 'zod';
import { error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { validateBody, parsePositiveIntParam } from '$lib/server/api-helpers';
import { recordView } from '$lib/server/recipes/views';
const Schema = z.object({
profile_id: z.number().int().positive()
});
export const POST: RequestHandler = async ({ params, request }) => {
const recipeId = parsePositiveIntParam(params.id, 'id');
const body = validateBody(await request.json().catch(() => null), Schema);
try {
recordView(getDb(), body.profile_id, recipeId);
} catch (e) {
// FK violation (unknown profile or recipe) → 404
if (e instanceof Error && /FOREIGN KEY constraint failed/i.test(e.message)) {
error(404, { message: 'Recipe or profile not found' });
}
throw e;
}
return new Response(null, { status: 204 });
};

View File

@@ -6,13 +6,30 @@ import {
type AllRecipesSort type AllRecipesSort
} from '$lib/server/recipes/search-local'; } from '$lib/server/recipes/search-local';
const VALID_SORTS = new Set<AllRecipesSort>(['name', 'rating', 'cooked', 'created']); const VALID_SORTS = new Set<AllRecipesSort>([
'name',
'rating',
'cooked',
'created',
'viewed'
]);
function parseProfileId(raw: string | null): number | null {
if (!raw) return null;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
}
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort; const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort;
if (!VALID_SORTS.has(sortRaw)) error(400, { message: 'Invalid sort' }); if (!VALID_SORTS.has(sortRaw)) error(400, { message: 'Invalid sort' });
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10))); // Cap is 200 (not 10's typical paging step) to support snapshot-based
// pagination restore on /+page.svelte: when the user navigates back
// after deep infinite-scroll, we re-hydrate the full loaded count in
// one round-trip so document height matches and scroll-restore lands.
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0)); const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset); const profileId = parseProfileId(url.searchParams.get('profile_id'));
const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset, profileId);
return json({ sort: sortRaw, limit, offset, hits }); return json({ sort: sortRaw, limit, offset, hits });
}; };

View File

@@ -0,0 +1,183 @@
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 + Error-Message loggen, niemals Prompt/Response-Inhalt.
// e.message enthaelt z.B. Zod-Validierungspfade oder "non-JSON output" --
// kein AI-Content, aber die Diagnose-Info, warum AI_FAILED kam.
console.warn(
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes: ${e.message}`
);
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

@@ -7,13 +7,9 @@ export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q')?.trim() ?? ''; const q = url.searchParams.get('q')?.trim() ?? '';
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100); const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0)); const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
const domains = (url.searchParams.get('domains') ?? '')
.split(',')
.map((d) => d.trim())
.filter(Boolean);
const hits = const hits =
q.length >= 1 q.length >= 1
? searchLocal(getDb(), q, limit, offset, domains) ? searchLocal(getDb(), q, limit, offset)
: offset === 0 : offset === 0
? listRecentRecipes(getDb(), limit) ? listRecentRecipes(getDb(), limit)
: []; : [];

View File

@@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { clearCart, listShoppingList } from '$lib/server/shopping/repository';
export const GET: RequestHandler = async () => {
return json(listShoppingList(getDb()));
};
export const DELETE: RequestHandler = async () => {
clearCart(getDb());
return json({ ok: true });
};

View File

@@ -0,0 +1,23 @@
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 { toggleCheck } from '$lib/server/shopping/repository';
const CheckSchema = z.object({
name_key: z.string().min(1).max(200),
unit_key: z.string().max(50) // kann leer sein
});
export const POST: RequestHandler = async ({ request }) => {
const data = validateBody(await request.json().catch(() => null), CheckSchema);
toggleCheck(getDb(), data.name_key, data.unit_key, true);
return json({ ok: true });
};
export const DELETE: RequestHandler = async ({ request }) => {
const data = validateBody(await request.json().catch(() => null), CheckSchema);
toggleCheck(getDb(), data.name_key, data.unit_key, false);
return json({ ok: true });
};

View File

@@ -0,0 +1,9 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { clearCheckedItems } from '$lib/server/shopping/repository';
export const DELETE: RequestHandler = async () => {
clearCheckedItems(getDb());
return json({ ok: true });
};

View File

@@ -0,0 +1,18 @@
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 { addRecipeToCart } from '$lib/server/shopping/repository';
const AddSchema = z.object({
recipe_id: z.number().int().positive(),
servings: z.number().int().min(1).max(50).optional(),
profile_id: z.number().int().positive().optional()
});
export const POST: RequestHandler = async ({ request }) => {
const data = validateBody(await request.json().catch(() => null), AddSchema);
addRecipeToCart(getDb(), data.recipe_id, data.profile_id ?? null, data.servings);
return json({ ok: true }, { status: 201 });
};

View File

@@ -0,0 +1,23 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { z } from 'zod';
import { getDb } from '$lib/server/db';
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
import { removeRecipeFromCart, setCartServings } from '$lib/server/shopping/repository';
const PatchSchema = z.object({
servings: z.number().int().min(1).max(50)
});
export const PATCH: RequestHandler = async ({ params, request }) => {
const id = parsePositiveIntParam(params.recipe_id, 'recipe_id');
const data = validateBody(await request.json().catch(() => null), PatchSchema);
setCartServings(getDb(), id, data.servings);
return json({ ok: true });
};
export const DELETE: RequestHandler = async ({ params }) => {
const id = parsePositiveIntParam(params.recipe_id, 'recipe_id');
removeRecipeFromCart(getDb(), id);
return json({ ok: true });
};

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

@@ -355,9 +355,28 @@
} }
}; };
document.addEventListener('visibilitychange', onVisibility); document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility);
}); });
// Track view per active profile (fire-and-forget). Lives in $effect, not
// onMount, because profileStore.load() runs from layout's onMount and the
// child onMount fires first — at mount time profileStore.active is still
// null on cold loads. The effect re-runs once active populates, the
// viewBeaconSent flag prevents duplicate POSTs on subsequent profile
// switches within the same page instance.
let viewBeaconSent = $state(false);
$effect(() => {
if (viewBeaconSent) return;
if (!profileStore.active) return;
viewBeaconSent = true;
void fetch(`/api/recipes/${data.recipe.id}/view`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileStore.active.id })
});
});
onDestroy(() => { onDestroy(() => {
void releaseWakeLock(); void releaseWakeLock();
}); });

View File

@@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ShoppingCart } from 'lucide-svelte';
import type { ShoppingListSnapshot } from '$lib/server/shopping/repository';
import ShoppingListRow from '$lib/components/ShoppingListRow.svelte';
import ShoppingCartChip from '$lib/components/ShoppingCartChip.svelte';
import type { ShoppingListRow as Row } from '$lib/server/shopping/repository';
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
import { confirmAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
let snapshot = $state<ShoppingListSnapshot>({ recipes: [], rows: [], uncheckedCount: 0 });
let loading = $state(true);
const hasChecked = $derived(snapshot.rows.some((r) => r.checked === 1));
async function load() {
loading = true;
try {
const res = await fetch('/api/shopping-list');
snapshot = await res.json();
} finally {
loading = false;
}
}
async function onToggleRow(row: Row, next: boolean) {
if (!requireOnline('Abhaken')) return;
const method = next ? 'POST' : 'DELETE';
await fetch('/api/shopping-list/check', {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name_key: row.name_key, unit_key: row.unit_key })
});
await load();
void shoppingCartStore.refresh();
}
async function onServingsChange(recipeId: number, servings: number) {
if (!requireOnline('Portionen-Aenderung')) return;
await fetch(`/api/shopping-list/recipe/${recipeId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servings })
});
await load();
void shoppingCartStore.refresh();
}
async function onRemoveRecipe(recipeId: number) {
if (!requireOnline('Rezept-Entfernung')) return;
await fetch(`/api/shopping-list/recipe/${recipeId}`, { method: 'DELETE' });
await load();
void shoppingCartStore.refresh();
}
async function clearChecked() {
if (!requireOnline('Erledigte entfernen')) return;
await fetch('/api/shopping-list/checked', { method: 'DELETE' });
await load();
void shoppingCartStore.refresh();
}
async function clearAll() {
if (!requireOnline('Liste leeren')) return;
const ok = await confirmAction({
title: 'Einkaufsliste leeren?',
message: 'Alle Rezepte und abgehakten Zutaten werden entfernt. Das lässt sich nicht rückgängig machen.',
confirmLabel: 'Leeren',
destructive: true
});
if (!ok) return;
await fetch('/api/shopping-list', { method: 'DELETE' });
await load();
void shoppingCartStore.refresh();
}
onMount(load);
</script>
<header class="head">
<h1>Einkaufsliste</h1>
{#if snapshot.recipes.length > 0}
<p class="sub">
{snapshot.uncheckedCount} noch zu besorgen · {snapshot.recipes.length} Rezept{snapshot.recipes.length === 1 ? '' : 'e'} im Wagen
</p>
{/if}
</header>
{#if loading}
<p class="muted">Lädt …</p>
{:else if snapshot.recipes.length === 0}
<section class="empty">
<div class="big"><ShoppingCart size={48} strokeWidth={1.5} /></div>
<p>Einkaufswagen ist leer.</p>
<p class="hint">Lege Rezepte auf der Wunschliste in den Wagen, um sie hier zu sehen.</p>
</section>
{:else}
<div class="chips">
{#each snapshot.recipes as r (r.recipe_id)}
<ShoppingCartChip recipe={r} {onServingsChange} onRemove={onRemoveRecipe} />
{/each}
</div>
<ul class="list">
{#each snapshot.rows as row (row.name_key + '|' + row.unit_key)}
<li>
<ShoppingListRow {row} onToggle={onToggleRow} />
</li>
{/each}
</ul>
<div class="footer">
{#if hasChecked}
<button class="btn secondary" onclick={clearChecked}>Erledigte entfernen</button>
{/if}
<button class="btn destructive" onclick={clearAll}>Liste leeren</button>
</div>
{/if}
<style>
.head { padding: 1.25rem 0 0.5rem; }
.head h1 { margin: 0; font-size: 1.6rem; color: #2b6a3d; }
.sub { margin: 0.2rem 0 0; color: #666; }
.muted { color: #888; text-align: center; padding: 2rem 0; }
.empty { text-align: center; padding: 3rem 1rem; }
.big { color: #8fb097; display: inline-flex; margin: 0 0 0.5rem; }
.hint { color: #888; font-size: 0.9rem; }
.list {
list-style: none;
padding: 0;
margin: 0.75rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chips {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 0.5rem 0;
margin: 0.5rem 0;
-webkit-overflow-scrolling: touch;
}
.footer {
position: sticky;
bottom: 0;
background: #f4f8f5;
padding: 0.75rem 0;
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
border-top: 1px solid #e4eae7;
}
.btn {
padding: 0.6rem 1rem;
border-radius: 10px;
border: 1px solid #cfd9d1;
background: white;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
min-height: 44px;
}
.btn.secondary { color: #2b6a3d; border-color: #b7d6c2; }
.btn.destructive { color: #c53030; border-color: #f1b4b4; }
.btn.destructive:hover { background: #fdf3f3; }
</style>

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Utensils, Trash2, CookingPot } from 'lucide-svelte'; import { Utensils, Trash2, CookingPot, ShoppingCart } from 'lucide-svelte';
import { profileStore, requireProfile } from '$lib/client/profile.svelte'; import { profileStore, requireProfile } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte'; import { wishlistStore } from '$lib/client/wishlist.svelte';
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
import { confirmAction } from '$lib/client/confirm.svelte'; import { confirmAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online'; import { requireOnline } from '$lib/client/require-online';
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository'; import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
@@ -70,9 +71,19 @@
void wishlistStore.refresh(); void wishlistStore.refresh();
} }
async function toggleCart(entry: WishlistEntry) {
if (!requireOnline('Die Einkaufsliste')) return;
if (shoppingCartStore.isInCart(entry.recipe_id)) {
await shoppingCartStore.removeRecipe(entry.recipe_id);
} else {
await shoppingCartStore.addRecipe(entry.recipe_id);
}
}
onMount(() => { onMount(() => {
void load(); void load();
void wishlistStore.refresh(); void wishlistStore.refresh();
void shoppingCartStore.refresh();
}); });
function resolveImage(p: string | null): string | null { function resolveImage(p: string | null): string | null {
@@ -125,16 +136,13 @@
{#if e.wanted_by_names} {#if e.wanted_by_names}
<span class="wanted-by">{e.wanted_by_names}</span> <span class="wanted-by">{e.wanted_by_names}</span>
{/if} {/if}
{#if e.source_domain}
<span class="src">· {e.source_domain}</span>
{/if}
{#if e.avg_stars !== null} {#if e.avg_stars !== null}
<span>· ★ {e.avg_stars.toFixed(1)}</span> <span>· ★ {e.avg_stars.toFixed(1)}</span>
{/if} {/if}
</div> </div>
</div> </div>
</a> </a>
<div class="actions"> <div class="actions-top">
<button <button
class="like" class="like"
class:active={e.on_my_wishlist} class:active={e.on_my_wishlist}
@@ -146,6 +154,16 @@
<span class="count">{e.wanted_by_count}</span> <span class="count">{e.wanted_by_count}</span>
{/if} {/if}
</button> </button>
<button
class="cart"
class:active={shoppingCartStore.isInCart(e.recipe_id)}
aria-label={shoppingCartStore.isInCart(e.recipe_id)
? 'Aus Einkaufswagen entfernen'
: 'In den Einkaufswagen'}
onclick={() => toggleCart(e)}
>
<ShoppingCart size={18} strokeWidth={2} />
</button>
<button <button
class="del" class="del"
aria-label="Für alle entfernen" aria-label="Für alle entfernen"
@@ -227,6 +245,7 @@
gap: 0.75rem; gap: 0.75rem;
} }
.card { .card {
position: relative;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
background: white; background: white;
@@ -255,7 +274,7 @@
} }
.text { .text {
flex: 1; flex: 1;
padding: 0.7rem 0.75rem; padding: 0.7rem 170px 0.7rem 0.75rem;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -265,6 +284,8 @@
font-weight: 600; font-weight: 600;
font-size: 1rem; font-size: 1rem;
line-height: 1.3; line-height: 1.3;
overflow-wrap: break-word;
word-break: break-word;
} }
.meta { .meta {
display: flex; display: flex;
@@ -278,18 +299,19 @@
color: #2b6a3d; color: #2b6a3d;
font-weight: 500; font-weight: 500;
} }
.actions { .actions-top {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex; display: flex;
flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
align-items: stretch; z-index: 1;
justify-content: center;
padding: 0.5rem 0.6rem 0.5rem 0;
} }
.like, .like,
.cart,
.del { .del {
min-width: 48px; min-width: 44px;
min-height: 40px; min-height: 44px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #e4eae7; border: 1px solid #e4eae7;
background: white; background: white;
@@ -297,8 +319,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.3rem; gap: 0.25rem;
font-size: 1.05rem; font-size: 1rem;
color: #444; color: #444;
} }
.like.active { .like.active {
@@ -306,6 +328,11 @@
background: #eaf4ed; background: #eaf4ed;
border-color: #b7d6c2; border-color: #b7d6c2;
} }
.cart.active {
color: #2b6a3d;
background: #eaf4ed;
border-color: #b7d6c2;
}
.del:hover { .del:hover {
color: #c53030; color: #c53030;
border-color: #f1b4b4; border-color: #f1b4b4;
@@ -315,4 +342,51 @@
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
} }
/* Handy: 2-Spalten-Grid — Bild links ueber alle Rows, rechts stapeln
sich Titel, Meta, Actions. `display: contents` auf .body/.text zieht
die DOM-Kinder direkt in die Card-Grid, ohne Markup-Umbau. Vermeidet
die tote Weissflaeche unter dem Bild bei schmalen Viewports. */
@media (max-width: 600px) {
.card {
display: grid;
grid-template-columns: 96px 1fr;
grid-template-areas:
'img title'
'img meta'
'img actions';
column-gap: 0;
}
.body {
display: contents;
}
.body img,
.placeholder {
grid-area: img;
width: 96px;
height: 100%;
min-height: 100%;
}
.text {
display: contents;
}
.title {
grid-area: title;
padding: 0.7rem 0.75rem 0.15rem;
}
.meta {
grid-area: meta;
padding: 0 0.75rem;
margin-top: 0;
}
.actions-top {
grid-area: actions;
position: static;
display: flex;
gap: 0.4rem;
padding: 0.5rem 0.75rem 0.7rem;
justify-content: flex-end;
align-self: end;
}
}
</style> </style>

View File

@@ -56,11 +56,13 @@ self.addEventListener('fetch', (event) => {
event.respondWith(cacheFirst(req, SHELL_CACHE)); event.respondWith(cacheFirst(req, SHELL_CACHE));
} else if (strategy === 'images') { } else if (strategy === 'images') {
event.respondWith(cacheFirst(req, IMAGES_CACHE)); event.respondWith(cacheFirst(req, IMAGES_CACHE));
} else if (strategy === 'swr') { } else if (strategy === 'network-first') {
event.respondWith(staleWhileRevalidate(req, DATA_CACHE)); event.respondWith(networkFirstWithTimeout(req, DATA_CACHE, NETWORK_TIMEOUT_MS));
} }
}); });
const NETWORK_TIMEOUT_MS = 3000;
async function cacheFirst(req: Request, cacheName: string): Promise<Response> { async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
const hit = await cache.match(req); const hit = await cache.match(req);
@@ -70,16 +72,36 @@ async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
return fresh; 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 cache = await caches.open(cacheName);
const hit = await cache.match(req); const networkPromise: Promise<Response | null> = fetch(req)
const fetchPromise = fetch(req)
.then((res) => { .then((res) => {
if (res.ok) cache.put(req, res.clone()).catch(() => {}); if (res.ok) cache.put(req, res.clone()).catch(() => {});
return res; return res;
}) })
.catch(() => hit ?? Response.error()); .catch(() => null);
return hit ?? fetchPromise;
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'; const META_CACHE = 'kochwas-meta';

View File

@@ -65,3 +65,10 @@ export async function cleanupE2EComments(
} }
} }
} }
/**
* Leert den haushaltsweiten Einkaufswagen. Idempotent.
*/
export async function clearShoppingCart(api: APIRequestContext): Promise<void> {
await api.delete('/api/shopping-list');
}

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,117 @@
import { test, expect } from '@playwright/test';
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
import { clearShoppingCart } from './fixtures/api-cleanup';
test.describe('Einkaufsliste E2E', () => {
test.beforeEach(async ({ request }) => {
await clearShoppingCart(request);
});
test.afterEach(async ({ request }) => {
await clearShoppingCart(request);
});
test('Cart-Button auf der Wunschliste erzeugt Header-Badge', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
// Voraussetzung: Dev-System hat mindestens einen Wunschlisten-Eintrag
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer auf Dev — Test uebersprungen');
await page.goto('/wishlist');
await page.getByLabel('In den Einkaufswagen').first().click();
await expect(page.getByLabel(/Einkaufsliste \(\d+\)/)).toBeVisible();
});
test('Shopping-List-Seite zeigt Rezept-Chip + Zutaten', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
const recipeId = wlBody.entries[0].recipe_id;
await request.post('/api/shopping-list/recipe', { data: { recipe_id: recipeId } });
await page.goto('/shopping-list');
await expect(page.getByRole('heading', { level: 1, name: 'Einkaufsliste' })).toBeVisible();
// Chip fuers Rezept sichtbar
await expect(page.getByLabel('Portion weniger').first()).toBeVisible();
// Mindestens eine Zutatenzeile
await expect(page.locator('.row').first()).toBeVisible();
});
test('Portions-Stepper veraendert Mengen live', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
await request.post('/api/shopping-list/recipe', {
data: { recipe_id: wlBody.entries[0].recipe_id, servings: 4 }
});
await page.goto('/shopping-list');
// Menge der ersten Zeile "vorher" lesen
const qtyBefore = await page.locator('.qty').first().textContent();
// Portion +1
await page.getByLabel('Portion mehr').first().click();
// Nach Fetch+Rerender muss die Menge sich aendern (ungleich dem Vorher-Wert)
await expect
.poll(async () => (await page.locator('.qty').first().textContent())?.trim())
.not.toBe(qtyBefore?.trim());
});
test('Abhaken: Zeile durchgestrichen, Badge-Count sinkt, persistiert nach Reload', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
await request.post('/api/shopping-list/recipe', {
data: { recipe_id: wlBody.entries[0].recipe_id }
});
await page.goto('/shopping-list');
const countBadge = page.getByLabel(/Einkaufsliste \(\d+\)/);
const badgeTextBefore = await countBadge.textContent();
const numBefore = Number((badgeTextBefore ?? '').replace(/\D+/g, '')) || 0;
// Anzahl abgehakter Zeilen vorher (sollte 0 sein, weil beforeEach cart leert)
const checkedBefore = await page.locator('label.row.checked').count();
// Erste Zeile abhaken — Playwright laesst die Checkbox direkt interagieren
await page.locator('label.row').first().locator('input[type=checkbox]').check();
// Nach Store-Refresh sortiert SQL "ORDER BY checked ASC" abgehakte ans
// Ende, also pruefen wir die Gesamtzahl, nicht die Position.
await expect(page.locator('label.row.checked')).toHaveCount(checkedBefore + 1);
// Badge muss sinken (nach Store-Refresh)
await expect
.poll(async () => {
const t = (await countBadge.textContent()) ?? '';
return Number(t.replace(/\D+/g, '')) || 0;
})
.toBeLessThan(numBefore);
// Reload persistiert
await page.reload();
await expect(page.locator('label.row.checked').first()).toBeVisible();
});
test('Liste leeren: Confirm + Empty-State + Badge weg', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
await request.post('/api/shopping-list/recipe', {
data: { recipe_id: wlBody.entries[0].recipe_id }
});
await page.goto('/shopping-list');
await page.getByRole('button', { name: 'Liste leeren' }).click();
// Confirm-Dialog (ConfirmAction nutzt einen App-eigenen Dialog, kein native)
await page.getByRole('button', { name: 'Leeren', exact: true }).click();
await expect(page.getByText('Einkaufswagen ist leer.')).toBeVisible();
await expect(page.getByLabel(/Einkaufsliste \(\d+\)/)).toHaveCount(0);
});
});

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

@@ -0,0 +1,270 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db';
// ---------------------------------------------------------------------------
// Module-level mock so the POST handler uses the in-memory test DB.
// Must be declared before any import of the handler itself.
// ---------------------------------------------------------------------------
const { testDb } = vi.hoisted(() => ({
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 { recordView, listViews } from '../../src/lib/server/recipes/views';
import { createProfile } from '../../src/lib/server/profiles/repository';
import { listAllRecipesPaginated } from '../../src/lib/server/recipes/search-local';
import { POST } from '../../src/routes/api/recipes/[id]/view/+server';
function seedRecipe(db: ReturnType<typeof openInMemoryForTest>, title: string): number {
const r = db
.prepare("INSERT INTO recipe (title, created_at) VALUES (?, datetime('now')) RETURNING id")
.get(title) as { id: number };
return r.id;
}
function mkReq(body: unknown) {
return new Request('http://test/api/recipes/1/view', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
}
describe('014_recipe_views migration', () => {
it('creates recipe_view table with expected columns', () => {
const db = openInMemoryForTest();
const cols = db.prepare("PRAGMA table_info(recipe_view)").all() as Array<{
name: string;
type: string;
notnull: number;
pk: number;
}>;
const byName = Object.fromEntries(cols.map((c) => [c.name, c]));
expect(byName.profile_id?.type).toBe('INTEGER');
expect(byName.profile_id?.notnull).toBe(1);
expect(byName.profile_id?.pk).toBe(1);
expect(byName.recipe_id?.type).toBe('INTEGER');
expect(byName.recipe_id?.notnull).toBe(1);
expect(byName.recipe_id?.pk).toBe(2);
expect(byName.last_viewed_at?.type).toBe('TIMESTAMP');
expect(byName.last_viewed_at?.notnull).toBe(1);
});
it('has index on (profile_id, last_viewed_at DESC)', () => {
const db = openInMemoryForTest();
const idxList = db
.prepare("PRAGMA index_list(recipe_view)")
.all() as Array<{ name: string }>;
expect(idxList.some((i) => i.name === 'idx_recipe_view_recent')).toBe(true);
});
});
describe('recordView', () => {
it('inserts a view row with default timestamp', () => {
const db = openInMemoryForTest();
const profile = createProfile(db, 'Test');
const recipeId = seedRecipe(db, 'Pasta');
recordView(db, profile.id, recipeId);
const rows = listViews(db, profile.id);
expect(rows.length).toBe(1);
expect(rows[0].recipe_id).toBe(recipeId);
expect(rows[0].last_viewed_at).toMatch(/^\d{4}-\d{2}-\d{2}/);
});
it('updates timestamp on subsequent view of same recipe', async () => {
const db = openInMemoryForTest();
const profile = createProfile(db, 'Test');
const recipeId = seedRecipe(db, 'Pasta');
recordView(db, profile.id, recipeId);
const first = listViews(db, profile.id)[0].last_viewed_at;
// tiny delay so the second timestamp differs
await new Promise((r) => setTimeout(r, 1100));
recordView(db, profile.id, recipeId);
const rows = listViews(db, profile.id);
expect(rows.length).toBe(1);
expect(rows[0].last_viewed_at >= first).toBe(true);
});
it('throws on unknown profile_id (FK)', () => {
const db = openInMemoryForTest();
const recipeId = seedRecipe(db, 'Pasta');
expect(() => recordView(db, 999, recipeId)).toThrow();
});
it('throws on unknown recipe_id (FK)', () => {
const db = openInMemoryForTest();
const profile = createProfile(db, 'Test');
expect(() => recordView(db, profile.id, 999)).toThrow();
});
});
describe("listAllRecipesPaginated sort='viewed'", () => {
it('puts recently-viewed recipes first, NULLs alphabetically last', async () => {
const db = openInMemoryForTest();
const profile = createProfile(db, 'Test');
const recipeA = seedRecipe(db, 'Apfelkuchen');
const recipeB = seedRecipe(db, 'Brokkoli');
// Inserted in reverse-alphabetical order (Z before D) to prove the
// tiebreaker sorts by title, not insertion order.
const recipeC = seedRecipe(db, 'Zwiebelkuchen');
const recipeD = seedRecipe(db, 'Donauwelle');
// View order: B then A. C and D never viewed.
recordView(db, profile.id, recipeB);
await new Promise((r) => setTimeout(r, 1100));
recordView(db, profile.id, recipeA);
const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, profile.id);
// Viewed: A (most recent), B — then unviewed alphabetically: D before C.
expect(hits.map((h) => h.id)).toEqual([recipeA, recipeB, recipeD, recipeC]);
});
it('falls back to alphabetical when profileId is null', () => {
const db = openInMemoryForTest();
seedRecipe(db, 'Couscous');
seedRecipe(db, 'Apfelkuchen');
seedRecipe(db, 'Brokkoli');
const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, null);
expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']);
});
it('keeps existing sorts working unchanged', () => {
const db = openInMemoryForTest();
seedRecipe(db, 'Couscous');
seedRecipe(db, 'Apfelkuchen');
seedRecipe(db, 'Brokkoli');
const hits = listAllRecipesPaginated(db, 'name', 50, 0);
expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']);
});
});
// ---------------------------------------------------------------------------
// POST /api/recipes/[id]/view — endpoint integration tests
// ---------------------------------------------------------------------------
beforeEach(() => {
testDb.current = openInMemoryForTest();
});
describe('POST /api/recipes/[id]/view', () => {
it('204 + view row written on success', async () => {
const db = testDb.current!;
const profile = createProfile(db, 'Tester');
const recipeId = seedRecipe(db, 'Pasta');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await POST({ params: { id: String(recipeId) }, request: mkReq({ profile_id: profile.id }) } as any);
expect(res.status).toBe(204);
const rows = listViews(db, profile.id);
expect(rows.length).toBe(1);
expect(rows[0].recipe_id).toBe(recipeId);
});
it('400 on recipe id = 0', async () => {
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST({ params: { id: '0' }, request: mkReq({ profile_id: 1 }) } as any)
).rejects.toMatchObject({ status: 400 });
});
it('400 on non-numeric recipe id', async () => {
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST({ params: { id: 'abc' }, request: mkReq({ profile_id: 1 }) } as any)
).rejects.toMatchObject({ status: 400 });
});
it('400 on missing profile_id in body', async () => {
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST({ params: { id: '1' }, request: mkReq({}) } as any)
).rejects.toMatchObject({ status: 400 });
});
it('400 on non-positive profile_id', async () => {
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST({ params: { id: '1' }, request: mkReq({ profile_id: 0 }) } as any)
).rejects.toMatchObject({ status: 400 });
});
it('400 on malformed JSON body', async () => {
const badReq = new Request('http://test/api/recipes/1/view', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: 'not-json'
});
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST({ params: { id: '1' }, request: badReq } as any)
).rejects.toMatchObject({ status: 400 });
});
it('404 on unknown profile_id (FK violation)', async () => {
const recipeId = seedRecipe(testDb.current!, 'Pasta');
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST({ params: { id: String(recipeId) }, request: mkReq({ profile_id: 999 }) } as any)
).rejects.toMatchObject({ status: 404 });
});
it('404 on unknown recipe_id (FK violation)', async () => {
const profile = createProfile(testDb.current!, 'Tester');
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
POST({ params: { id: '99999' }, request: mkReq({ profile_id: profile.id }) } as any)
).rejects.toMatchObject({ status: 404 });
});
});
// ---------------------------------------------------------------------------
// GET /api/recipes/all — sort=viewed + profile_id
// ---------------------------------------------------------------------------
import { GET as allGet } from '../../src/routes/api/recipes/all/+server';
describe('GET /api/recipes/all sort=viewed', () => {
it('passes profile_id through and returns viewed-order hits', async () => {
const db = openInMemoryForTest();
testDb.current = db;
const profile = createProfile(db, 'Test');
const a = seedRecipe(db, 'Apfel');
const b = seedRecipe(db, 'Birne');
recordView(db, profile.id, b);
await new Promise((r) => setTimeout(r, 1100));
recordView(db, profile.id, a);
const url = new URL(`http://localhost/api/recipes/all?sort=viewed&profile_id=${profile.id}&limit=10`);
const res = await allGet({ url } as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.sort).toBe('viewed');
expect(body.hits.map((h: { id: number }) => h.id)).toEqual([a, b]);
});
it('400 on invalid sort', async () => {
const url = new URL('http://localhost/api/recipes/all?sort=invalid');
await expect(allGet({ url } as never)).rejects.toMatchObject({ status: 400 });
});
});

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

@@ -69,20 +69,11 @@ describe('searchLocal', () => {
expect(searchLocal(db, ' ')).toEqual([]); expect(searchLocal(db, ' ')).toEqual([]);
}); });
it('filters by domain when supplied', () => { it('ignores source_domain — local search is domain-agnostic', () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' })); insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' })); insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
const hits = searchLocal(db, 'apfel', 10, 0, ['chefkoch.de']); const hits = searchLocal(db, 'apfel');
expect(hits.length).toBe(1);
expect(hits[0].source_domain).toBe('chefkoch.de');
});
it('no domain filter when array is empty', () => {
const db = openInMemoryForTest();
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
const hits = searchLocal(db, 'apfel', 10, 0, []);
expect(hits.length).toBe(2); expect(hits.length).toBe(2);
}); });

View File

@@ -0,0 +1,481 @@
import { describe, it, expect } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db';
import { insertRecipe } from '../../src/lib/server/recipes/repository';
import {
addRecipeToCart,
removeRecipeFromCart,
listShoppingList,
setCartServings,
toggleCheck,
clearCheckedItems,
clearCart
} from '../../src/lib/server/shopping/repository';
import type { Recipe } from '../../src/lib/types';
function recipe(overrides: Partial<Recipe> = {}): Recipe {
return {
id: null,
title: 'Test',
description: null,
source_url: null,
source_domain: null,
image_path: null,
servings_default: 4,
servings_unit: null,
prep_time_min: null,
cook_time_min: null,
total_time_min: null,
cuisine: null,
category: null,
ingredients: [],
steps: [],
tags: [],
...overrides
};
}
describe('addRecipeToCart', () => {
it('inserts recipe with default servings from recipe.servings_default', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({ title: 'Pasta', servings_default: 4 }));
addRecipeToCart(db, id, null);
const snap = listShoppingList(db);
expect(snap.recipes).toHaveLength(1);
expect(snap.recipes[0].servings).toBe(4);
});
it('respects explicit servings override', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({ servings_default: 4 }));
addRecipeToCart(db, id, null, 2);
expect(listShoppingList(db).recipes[0].servings).toBe(2);
});
it('is idempotent: second insert updates servings, not fails', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({ servings_default: 4 }));
addRecipeToCart(db, id, null, 2);
addRecipeToCart(db, id, null, 6);
const snap = listShoppingList(db);
expect(snap.recipes).toHaveLength(1);
expect(snap.recipes[0].servings).toBe(6);
});
it('falls back to servings=4 when recipe has no default', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({ servings_default: null }));
addRecipeToCart(db, id, null);
expect(listShoppingList(db).recipes[0].servings).toBe(4);
});
});
describe('removeRecipeFromCart', () => {
it('deletes only the given recipe', () => {
const db = openInMemoryForTest();
const a = insertRecipe(db, recipe({ title: 'A' }));
const b = insertRecipe(db, recipe({ title: 'B' }));
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
removeRecipeFromCart(db, a);
const snap = listShoppingList(db);
expect(snap.recipes).toHaveLength(1);
expect(snap.recipes[0].recipe_id).toBe(b);
});
it('is idempotent when recipe is not in cart', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe());
expect(() => removeRecipeFromCart(db, id)).not.toThrow();
});
});
describe('setCartServings', () => {
it('updates servings for a cart recipe', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe());
addRecipeToCart(db, id, null, 4);
setCartServings(db, id, 8);
expect(listShoppingList(db).recipes[0].servings).toBe(8);
});
it('rejects non-positive servings', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe());
addRecipeToCart(db, id, null, 4);
expect(() => setCartServings(db, id, 0)).toThrow();
expect(() => setCartServings(db, id, -3)).toThrow();
});
});
describe('listShoppingList aggregation', () => {
it('aggregates same name+unit across recipes', () => {
const db = openInMemoryForTest();
const a = insertRecipe(db, recipe({
title: 'Carbonara', servings_default: 4,
ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
}));
const b = insertRecipe(db, recipe({
title: 'Lasagne', servings_default: 4,
ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, a, null, 4);
addRecipeToCart(db, b, null, 4);
const rows = listShoppingList(db).rows;
expect(rows).toHaveLength(1);
expect(rows[0].name_key).toBe('mehl');
expect(rows[0].unit_key).toBe('weight');
expect(rows[0].total_quantity).toBe(400);
expect(rows[0].from_recipes).toContain('Carbonara');
expect(rows[0].from_recipes).toContain('Lasagne');
});
it('keeps different units as separate rows', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
servings_default: 4,
ingredients: [
{ position: 1, quantity: 100, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null },
{ position: 2, quantity: 1, unit: 'Pck', name: 'Mehl', note: null, raw_text: '', section_heading: null }
]
}));
addRecipeToCart(db, id, null, 4);
const rows = listShoppingList(db).rows;
expect(rows).toHaveLength(2);
});
it('scales quantities by servings/servings_default', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
servings_default: 4,
ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, id, null, 2);
expect(listShoppingList(db).rows[0].total_quantity).toBe(100);
});
it('null quantity stays null after aggregation', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
ingredients: [{ position: 1, quantity: null, unit: null, name: 'Salz', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, id, null);
const rows = listShoppingList(db).rows;
expect(rows[0].total_quantity).toBeNull();
expect(rows[0].unit_key).toBe('');
});
it('counts unchecked rows in uncheckedCount', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
ingredients: [
{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null },
{ position: 2, quantity: 1, unit: 'Stk', name: 'Birne', note: null, raw_text: '', section_heading: null }
]
}));
addRecipeToCart(db, id, null);
expect(listShoppingList(db).uncheckedCount).toBe(2);
});
it('does not blow up when servings_default is zero (silent NULL total_quantity)', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
servings_default: 0,
ingredients: [{ position: 1, quantity: 100, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, id, null, 4);
const rows = listShoppingList(db).rows;
expect(rows).toHaveLength(1);
expect(rows[0].total_quantity).toBeNull();
});
});
describe('toggleCheck', () => {
function setupOneRowCart() {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, id, null);
return { db, id };
}
it('marks a row as checked', () => {
const { db } = setupOneRowCart();
toggleCheck(db, 'mehl', 'weight', true);
const rows = listShoppingList(db).rows;
expect(rows[0].checked).toBe(1);
});
it('unchecks a row when passed false', () => {
const { db } = setupOneRowCart();
toggleCheck(db, 'mehl', 'weight', true);
toggleCheck(db, 'mehl', 'weight', false);
expect(listShoppingList(db).rows[0].checked).toBe(0);
});
it('check survives removal of one recipe when another still contributes', () => {
const db = openInMemoryForTest();
const a = insertRecipe(db, recipe({
title: 'A',
ingredients: [{ position: 1, quantity: 100, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
}));
const b = insertRecipe(db, recipe({
title: 'B',
ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
toggleCheck(db, 'mehl', 'weight', true);
// Rezept A weg, Mehl kommt noch aus B — check bleibt, mit neuer Menge
removeRecipeFromCart(db, a);
const rows = listShoppingList(db).rows;
expect(rows[0].checked).toBe(1);
expect(rows[0].total_quantity).toBe(200);
});
});
describe('clearCheckedItems', () => {
it('removes recipes where ALL rows are checked', () => {
const db = openInMemoryForTest();
const a = insertRecipe(db, recipe({
title: 'A',
ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }]
}));
const b = insertRecipe(db, recipe({
title: 'B',
ingredients: [
{ position: 1, quantity: 1, unit: 'Stk', name: 'Birne', note: null, raw_text: '', section_heading: null },
{ position: 2, quantity: 1, unit: 'Stk', name: 'Salz', note: null, raw_text: '', section_heading: null }
]
}));
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
toggleCheck(db, 'apfel', 'stk', true);
toggleCheck(db, 'birne', 'stk', true);
// Salz aus B noch nicht abgehakt → B bleibt, A fliegt
clearCheckedItems(db);
const snap = listShoppingList(db);
expect(snap.recipes.map((r) => r.recipe_id)).toEqual([b]);
// Birne-Check bleibt, weil B noch im Cart und Birne noch aktiv
const birneRow = snap.rows.find((r) => r.name_key === 'birne');
expect(birneRow?.checked).toBe(1);
});
it('purges orphan checks that no longer map to any cart recipe', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, id, null);
toggleCheck(db, 'apfel', 'stk', true);
clearCheckedItems(db);
// Apfel-Check haengt jetzt an nichts mehr → muss aus der Tabelle raus sein
const row = db
.prepare('SELECT * FROM shopping_cart_check WHERE name_key = ?')
.get('apfel');
expect(row).toBeUndefined();
});
it('is a no-op when nothing is checked', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, id, null);
clearCheckedItems(db);
expect(listShoppingList(db).recipes).toHaveLength(1);
});
});
describe('clearCart', () => {
it('deletes all cart recipes and all checks', () => {
const db = openInMemoryForTest();
const id = insertRecipe(db, recipe({
ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }]
}));
addRecipeToCart(db, id, null);
toggleCheck(db, 'apfel', 'stk', true);
clearCart(db);
const snap = listShoppingList(db);
expect(snap.recipes).toEqual([]);
expect(snap.rows).toEqual([]);
expect(snap.uncheckedCount).toBe(0);
const anyCheck = db.prepare('SELECT 1 FROM shopping_cart_check').get();
expect(anyCheck).toBeUndefined();
});
});
describe('toggleCheck — stabil ueber Unit-Family', () => {
it('haekchen bleibt erhalten wenn Gesamtmenge von kg auf g faellt', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [{ position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null }]
})
);
const b = insertRecipe(
db,
recipe({
title: 'R2',
servings_default: 4,
ingredients: [{ position: 1, name: 'Kartoffeln', quantity: 1, unit: 'kg', note: null, raw_text: '', section_heading: null }]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
// Abhaken der konsolidierten 1,5-kg-Zeile via family-key
const before = listShoppingList(db).rows[0];
toggleCheck(db, before.name_key, before.unit_key, true);
expect(listShoppingList(db).rows[0].checked).toBe(1);
// Ein Rezept rausnehmen → nur noch 500 g, display wechselt auf g
removeRecipeFromCart(db, b);
const after = listShoppingList(db).rows[0];
expect(after.display_unit).toBe('g');
expect(after.total_quantity).toBe(500);
// Haekchen bleibt: unit_key ist weiterhin 'weight'
expect(after.checked).toBe(1);
});
it('clearCheckedItems respektiert family-key beim Orphan-Cleanup', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [
{ position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null },
{ position: 2, name: 'Salz', quantity: 1, unit: 'Prise', note: null, raw_text: '', section_heading: null }
]
})
);
addRecipeToCart(db, a, null);
const rows = listShoppingList(db).rows;
// Alle abhaken
for (const r of rows) toggleCheck(db, r.name_key, r.unit_key, true);
clearCheckedItems(db);
// Das Rezept sollte raus sein
expect(listShoppingList(db).recipes).toHaveLength(0);
// Check-Tabelle sollte leer sein (keine Orphans)
const remaining = (db.prepare('SELECT COUNT(*) AS c FROM shopping_cart_check').get() as { c: number }).c;
expect(remaining).toBe(0);
});
});
describe('listShoppingList — Konsolidierung ueber Einheiten', () => {
it('fasst 500 g + 1 kg Kartoffeln zu 1,5 kg zusammen', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'Kartoffelsuppe',
servings_default: 4,
ingredients: [
{ position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null }
]
})
);
const b = insertRecipe(
db,
recipe({
title: 'Kartoffelpuffer',
servings_default: 4,
ingredients: [
{ position: 1, name: 'Kartoffeln', quantity: 1, unit: 'kg', note: null, raw_text: '', section_heading: null }
]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
const snap = listShoppingList(db);
const kartoffeln = snap.rows.filter((r) => r.display_name.toLowerCase() === 'kartoffeln');
expect(kartoffeln).toHaveLength(1);
expect(kartoffeln[0].total_quantity).toBe(1.5);
expect(kartoffeln[0].display_unit).toBe('kg');
});
it('kombiniert ml + l korrekt (400 ml + 0,5 l → 900 ml)', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [{ position: 1, name: 'Milch', quantity: 400, unit: 'ml', note: null, raw_text: '', section_heading: null }]
})
);
const b = insertRecipe(
db,
recipe({
title: 'R2',
servings_default: 4,
ingredients: [{ position: 1, name: 'Milch', quantity: 0.5, unit: 'l', note: null, raw_text: '', section_heading: null }]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
const milch = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'milch');
expect(milch).toHaveLength(1);
expect(milch[0].total_quantity).toBe(900);
expect(milch[0].display_unit).toBe('ml');
});
it('laesst inkompatible Families getrennt (5 Stueck Eier + 500 g Eier = 2 Zeilen)', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [{ position: 1, name: 'Eier', quantity: 5, unit: 'Stück', note: null, raw_text: '', section_heading: null }]
})
);
const b = insertRecipe(
db,
recipe({
title: 'R2',
servings_default: 4,
ingredients: [{ position: 1, name: 'Eier', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null }]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
const eier = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'eier');
expect(eier).toHaveLength(2);
});
it('summiert gleiche Unit-Family ohne Konversion (2 Bund + 1 Bund → 3 Bund)', () => {
const db = openInMemoryForTest();
const a = insertRecipe(
db,
recipe({
title: 'R1',
servings_default: 4,
ingredients: [{ position: 1, name: 'Petersilie', quantity: 2, unit: 'Bund', note: null, raw_text: '', section_heading: null }]
})
);
const b = insertRecipe(
db,
recipe({
title: 'R2',
servings_default: 4,
ingredients: [{ position: 1, name: 'Petersilie', quantity: 1, unit: 'Bund', note: null, raw_text: '', section_heading: null }]
})
);
addRecipeToCart(db, a, null);
addRecipeToCart(db, b, null);
const petersilie = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'petersilie');
expect(petersilie).toHaveLength(1);
expect(petersilie[0].total_quantity).toBe(3);
expect(petersilie[0].display_unit?.toLowerCase()).toBe('bund');
});
});

View File

@@ -6,14 +6,16 @@ describe('resolveStrategy', () => {
expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images'); expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images');
}); });
it('swr for recipe HTML pages', () => { it('network-first for recipe HTML pages', () => {
expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('swr'); expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('network-first');
}); });
it('swr for recipe API reads', () => { it('network-first for recipe API reads', () => {
expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('swr'); expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('network-first');
expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe('swr'); expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe(
expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('swr'); 'network-first'
);
expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('network-first');
}); });
it('network-only for write methods', () => { it('network-only for write methods', () => {
@@ -28,14 +30,20 @@ describe('resolveStrategy', () => {
expect(resolveStrategy({ url: '/api/recipes/search/web?q=x', method: 'GET' })).toBe('network-only'); expect(resolveStrategy({ url: '/api/recipes/search/web?q=x', method: 'GET' })).toBe('network-only');
}); });
it('network-only for /api/shopping-list/*', () => {
expect(resolveStrategy({ url: '/api/shopping-list', method: 'GET' })).toBe('network-only');
expect(resolveStrategy({ url: '/api/shopping-list/recipe/5', method: 'GET' })).toBe('network-only');
expect(resolveStrategy({ url: '/api/shopping-list/check', method: 'GET' })).toBe('network-only');
});
it('shell bucket for build/static assets', () => { it('shell bucket for build/static assets', () => {
expect(resolveStrategy({ url: '/_app/immutable/chunks/x.js', method: 'GET' })).toBe('shell'); expect(resolveStrategy({ url: '/_app/immutable/chunks/x.js', method: 'GET' })).toBe('shell');
expect(resolveStrategy({ url: '/icon-192.png', method: 'GET' })).toBe('shell'); expect(resolveStrategy({ url: '/icon-192.png', method: 'GET' })).toBe('shell');
expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell'); expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell');
}); });
it('falls through to swr for other same-origin GETs (e.g. root page)', () => { it('falls through to network-first for other same-origin GETs (e.g. root page)', () => {
expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('swr'); expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('network-first');
expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('swr'); 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,28 @@
import { describe, it, expect } from 'vitest';
import { formatQuantity } from '../../src/lib/quantity-format';
describe('formatQuantity', () => {
it('renders null as empty string', () => {
expect(formatQuantity(null)).toBe('');
});
it('renders whole numbers as integer', () => {
expect(formatQuantity(400)).toBe('400');
});
it('renders near-integer as integer (epsilon 0.01)', () => {
expect(formatQuantity(400.001)).toBe('400');
expect(formatQuantity(399.999)).toBe('400');
});
it('renders fractional with up to 2 decimals, trailing zeros trimmed', () => {
expect(formatQuantity(0.5)).toBe('0,5');
expect(formatQuantity(0.333333)).toBe('0,33');
expect(formatQuantity(1.1)).toBe('1,1');
expect(formatQuantity(1.1)).toBe('1,1');
});
it('handles zero', () => {
expect(formatQuantity(0)).toBe('0');
});
});

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

@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { recordScroll, restoreScroll } from '../../src/lib/client/scroll-restore';
const STORAGE_KEY = 'kochwas:scroll';
function setScrollY(y: number) {
Object.defineProperty(window, 'scrollY', { value: y, configurable: true });
}
function setDocHeight(h: number) {
Object.defineProperty(document.documentElement, 'scrollHeight', {
value: h,
configurable: true
});
}
function setViewportHeight(h: number) {
Object.defineProperty(window, 'innerHeight', { value: h, configurable: true });
}
function url(path: string): URL {
return new URL(path, 'https://example.test');
}
describe('scroll-restore', () => {
let scrollToSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
sessionStorage.clear();
setScrollY(0);
setViewportHeight(800);
setDocHeight(800);
scrollToSpy = vi
.spyOn(window, 'scrollTo')
.mockImplementation(() => undefined as unknown as void);
});
afterEach(() => {
scrollToSpy.mockRestore();
vi.useRealTimers();
});
it('records scrollY keyed by from-url pathname+search', () => {
setScrollY(1200);
recordScroll(url('/wishlist'));
const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
expect(map['/wishlist']).toBe(1200);
});
it('keeps separate entries per URL', () => {
setScrollY(500);
recordScroll(url('/wishlist'));
setScrollY(900);
recordScroll(url('/?q=hi'));
const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
expect(map['/wishlist']).toBe(500);
expect(map['/?q=hi']).toBe(900);
});
it('does not overwrite a stored URL when called with a different from-url', () => {
// This is the regression: on popstate, location.pathname is already
// the new URL. Recording must use nav.from.url (the page being left),
// not location, or we wipe the destination's saved scrollY.
setScrollY(500);
recordScroll(url('/'));
setScrollY(0);
recordScroll(url('/recipes/1'));
const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
expect(map['/']).toBe(500);
expect(map['/recipes/1']).toBe(0);
});
it('skips when from-url is missing', () => {
setScrollY(900);
recordScroll(null);
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('skips restore for non-popstate navigation', () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 }));
setDocHeight(5000);
restoreScroll('link', url('/wishlist'));
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('skips restore when to-url is missing', () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 }));
restoreScroll('popstate', null);
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('skips restore when no entry stored', () => {
setDocHeight(5000);
restoreScroll('popstate', url('/wishlist'));
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('skips restore for trivial scrollY (noise)', () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 10 }));
setDocHeight(5000);
restoreScroll('popstate', url('/wishlist'));
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('scrolls immediately when document is already tall enough', async () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 }));
setDocHeight(5000);
restoreScroll('popstate', url('/wishlist'));
await new Promise((r) => requestAnimationFrame(() => r(null)));
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, left: 0, behavior: 'instant' });
});
it('waits via rAF until document grows tall enough', async () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1500 }));
setDocHeight(900);
restoreScroll('popstate', url('/wishlist'));
await new Promise((r) => requestAnimationFrame(() => r(null)));
expect(scrollToSpy).not.toHaveBeenCalled();
setDocHeight(3000);
await new Promise((r) => requestAnimationFrame(() => r(null)));
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1500, left: 0, behavior: 'instant' });
});
});

View File

@@ -202,7 +202,7 @@ describe('SearchStore', () => {
expect(round).toEqual(snap); expect(round).toEqual(snap);
}); });
it('filterParam option: gets appended to both local and web requests', async () => { it('webFilterParam option: only appended to web requests, never to local', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const fetchImpl = mockFetch([ const fetchImpl = mockFetch([
{ body: { hits: [] } }, { body: { hits: [] } },
@@ -211,13 +211,15 @@ describe('SearchStore', () => {
const store = new SearchStore({ const store = new SearchStore({
fetchImpl, fetchImpl,
debounceMs: 10, debounceMs: 10,
filterParam: () => '&domains=chefkoch.de' webFilterParam: () => '&domains=chefkoch.de'
}); });
store.query = 'curry'; store.query = 'curry';
store.runDebounced(); store.runDebounced();
await vi.advanceTimersByTimeAsync(15); await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/); expect(fetchImpl.mock.calls[0][0]).not.toMatch(/domains=/);
expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?/);
expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?/);
expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/); expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/);
}); });
@@ -243,22 +245,25 @@ describe('SearchStore', () => {
const fetchImpl = mockFetch([ const fetchImpl = mockFetch([
{ body: { hits: [] } }, { body: { hits: [] } },
{ 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 }] } } { body: { hits: [] } },
{ body: { hits: [{ url: 'https://chefkoch.de/x', title: 'filtered', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } }
]); ]);
const store = new SearchStore({ const store = new SearchStore({
fetchImpl, fetchImpl,
debounceMs: 10, debounceMs: 10,
filterDebounceMs: 5, filterDebounceMs: 5,
filterParam: () => filter webFilterParam: () => filter
}); });
store.query = 'broth'; store.query = 'broth';
store.runDebounced(); store.runDebounced();
await vi.advanceTimersByTimeAsync(15); await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
filter = '&domains=chefkoch.de'; filter = '&domains=chefkoch.de';
store.reSearch(); store.reSearch();
await vi.advanceTimersByTimeAsync(10); await vi.advanceTimersByTimeAsync(10);
await vi.waitFor(() => expect(store.hits).toHaveLength(1)); await vi.waitFor(() => expect(store.webHits).toHaveLength(1));
const last = fetchImpl.mock.calls.at(-1)?.[0] as string; const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
expect(last).toMatch(/\/api\/recipes\/search\/web\?/);
expect(last).toMatch(/&domains=chefkoch\.de/); expect(last).toMatch(/&domains=chefkoch\.de/);
}); });
}); });

View File

@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { ShoppingCartStore } from '../../src/lib/client/shopping-cart.svelte';
type FetchMock = ReturnType<typeof vi.fn>;
function snapshotBody(opts: {
recipeIds?: number[];
uncheckedCount?: number;
}) {
return {
recipes: (opts.recipeIds ?? []).map((id) => ({
recipe_id: id, title: `R${id}`, image_path: null, servings: 4, servings_default: 4
})),
rows: [],
uncheckedCount: opts.uncheckedCount ?? 0
};
}
function makeFetch(responses: unknown[]): FetchMock {
const queue = [...responses];
return vi.fn(async () => ({
ok: true,
status: 200,
json: async () => queue.shift()
} as Response));
}
describe('ShoppingCartStore', () => {
it('refresh populates recipeIds and uncheckedCount', async () => {
const fetchImpl = makeFetch([snapshotBody({ recipeIds: [1, 2], uncheckedCount: 3 })]);
const store = new ShoppingCartStore(fetchImpl);
await store.refresh();
expect(store.uncheckedCount).toBe(3);
expect(store.isInCart(1)).toBe(true);
expect(store.isInCart(2)).toBe(true);
expect(store.isInCart(3)).toBe(false);
expect(store.loaded).toBe(true);
});
it('addRecipe posts then refreshes', async () => {
const fetchImpl = makeFetch([
{},
snapshotBody({ recipeIds: [42], uncheckedCount: 5 })
]);
const store = new ShoppingCartStore(fetchImpl);
await store.addRecipe(42);
expect(fetchImpl.mock.calls[0][0]).toBe('/api/shopping-list/recipe');
expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: 'POST' });
expect(store.isInCart(42)).toBe(true);
expect(store.uncheckedCount).toBe(5);
});
it('removeRecipe deletes then refreshes', async () => {
const fetchImpl = makeFetch([
{},
snapshotBody({ recipeIds: [], uncheckedCount: 0 })
]);
const store = new ShoppingCartStore(fetchImpl);
await store.removeRecipe(42);
expect(fetchImpl.mock.calls[0][0]).toBe('/api/shopping-list/recipe/42');
expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: 'DELETE' });
expect(store.uncheckedCount).toBe(0);
});
it('refresh keeps last known state on network error', async () => {
const fetchImpl = vi.fn().mockRejectedValue(new Error('offline'));
const store = new ShoppingCartStore(fetchImpl);
store.uncheckedCount = 7;
await store.refresh();
expect(store.uncheckedCount).toBe(7);
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { unitFamily, consolidate } from '../../src/lib/server/unit-consolidation';
describe('unitFamily', () => {
it('maps g and kg to weight', () => {
expect(unitFamily('g')).toBe('weight');
expect(unitFamily('kg')).toBe('weight');
});
it('maps ml and l to volume', () => {
expect(unitFamily('ml')).toBe('volume');
expect(unitFamily('l')).toBe('volume');
});
it('lowercases and trims unknown units', () => {
expect(unitFamily(' Bund ')).toBe('bund');
expect(unitFamily('TL')).toBe('tl');
expect(unitFamily('Stück')).toBe('stück');
});
it('is case-insensitive for weight/volume', () => {
expect(unitFamily('Kg')).toBe('weight');
expect(unitFamily('ML')).toBe('volume');
});
it('returns empty string for null/undefined/empty', () => {
expect(unitFamily(null)).toBe('');
expect(unitFamily(undefined)).toBe('');
expect(unitFamily('')).toBe('');
expect(unitFamily(' ')).toBe('');
});
});
describe('consolidate', () => {
it('kombiniert 500 g + 1 kg zu 1,5 kg', () => {
const out = consolidate([
{ quantity: 500, unit: 'g' },
{ quantity: 1, unit: 'kg' }
]);
expect(out).toEqual({ quantity: 1.5, unit: 'kg' });
});
it('bleibt bei g wenn Summe < 1 kg', () => {
const out = consolidate([
{ quantity: 200, unit: 'g' },
{ quantity: 300, unit: 'g' }
]);
expect(out).toEqual({ quantity: 500, unit: 'g' });
});
it('promoted bei exakt 1000 g (Boundary)', () => {
const out = consolidate([
{ quantity: 1000, unit: 'g' }
]);
expect(out).toEqual({ quantity: 1, unit: 'kg' });
});
it('kombiniert ml + l analog (400 ml + 0,5 l → 900 ml)', () => {
const out = consolidate([
{ quantity: 400, unit: 'ml' },
{ quantity: 0.5, unit: 'l' }
]);
expect(out).toEqual({ quantity: 900, unit: 'ml' });
});
it('promoted zu l ab 1000 ml (0,5 l + 0,8 l → 1,3 l)', () => {
const out = consolidate([
{ quantity: 0.5, unit: 'l' },
{ quantity: 0.8, unit: 'l' }
]);
expect(out).toEqual({ quantity: 1.3, unit: 'l' });
});
it('summiert gleiche nicht-family-units (2 Bund + 1 Bund → 3 Bund)', () => {
const out = consolidate([
{ quantity: 2, unit: 'Bund' },
{ quantity: 1, unit: 'Bund' }
]);
expect(out).toEqual({ quantity: 3, unit: 'Bund' });
});
it('behandelt quantity=null als 0', () => {
const out = consolidate([
{ quantity: null, unit: 'TL' },
{ quantity: 1, unit: 'TL' }
]);
expect(out).toEqual({ quantity: 1, unit: 'TL' });
});
it('gibt null zurueck wenn alle quantities null sind', () => {
const out = consolidate([
{ quantity: null, unit: 'Prise' },
{ quantity: null, unit: 'Prise' }
]);
expect(out).toEqual({ quantity: null, unit: 'Prise' });
});
it('rundet Float-Artefakte auf 2 Dezimalen (0,1 + 0,2 kg → 0,3 kg)', () => {
const out = consolidate([
{ quantity: 0.1, unit: 'kg' },
{ quantity: 0.2, unit: 'kg' }
]);
// 0.1 + 0.2 in kg = 0.3 kg, in g = 300 → promoted? 300 < 1000 → 300 g
expect(out).toEqual({ quantity: 300, unit: 'g' });
});
it('nimmt unit vom ersten Eintrag bei unbekannter family', () => {
const out = consolidate([{ quantity: 5, unit: 'Stück' }]);
expect(out).toEqual({ quantity: 5, unit: 'Stück' });
});
});

View File

@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], 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: { test: {
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts'],
globals: false, globals: false,