82 Commits
v1.3.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
53 changed files with 7927 additions and 165 deletions

2
.gitignore vendored
View File

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

View File

@@ -17,9 +17,10 @@ services:
- 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; Rezept-Fotos koennen bis 8 MB sein.
# Multipart-Overhead einrechnen -> 10 MB gibt etwas Puffer.
- BODY_SIZE_LIMIT=10000000
# adapter-node-Default ist 512 KB. Tablet- und iPad-Pro-Kameras liefern
# JPEGs/HEICs bis 15 MB. Endpoint-Limit ist 20 MB; hier 25 MB fuer den
# Multipart-Overhead.
- BODY_SIZE_LIMIT=25000000
depends_on:
- searxng
restart: unless-stopped

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,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.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "kochwas",
"version": "1.3.0",
"version": "1.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kochwas",
"version": "1.3.0",
"version": "1.4.2",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@types/archiver": "^7.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "kochwas",
"version": "1.3.0",
"version": "1.4.2",
"private": true,
"type": "module",
"scripts": {

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;
filterDebounceMs?: number;
minQueryLength?: number;
filterParam?: () => string;
webFilterParam?: () => string;
fetchImpl?: typeof fetch;
};
@@ -38,7 +38,7 @@ export class SearchStore {
private readonly debounceMs: number;
private readonly filterDebounceMs: number;
private readonly minQueryLength: number;
private readonly filterParam: () => string;
private readonly webFilterParam: () => string;
private readonly fetchImpl: typeof fetch;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private skipNextDebounce = false;
@@ -48,7 +48,7 @@ export class SearchStore {
this.debounceMs = opts.debounceMs ?? 300;
this.filterDebounceMs = opts.filterDebounceMs ?? 150;
this.minQueryLength = opts.minQueryLength ?? 4;
this.filterParam = opts.filterParam ?? (() => '');
this.webFilterParam = opts.webFilterParam ?? (() => '');
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
}
@@ -80,7 +80,7 @@ export class SearchStore {
this.webExhausted = false;
try {
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[] };
if (this.query.trim() !== q) return;
@@ -99,7 +99,7 @@ export class SearchStore {
this.webSearching = true;
try {
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 (!res.ok) {
@@ -125,7 +125,7 @@ export class SearchStore {
try {
if (!this.localExhausted) {
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[] };
if (this.query.trim() !== q) return;
@@ -140,7 +140,7 @@ export class SearchStore {
if (wasEmpty) this.webSearching = true;
try {
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 (!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

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

@@ -2,6 +2,7 @@ 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
@@ -84,7 +85,10 @@ async function callGemini(
const parts: Array<
{ inlineData: { data: string; mimeType: string } } | { text: string }
> = [{ inlineData: { data: imageBuffer.toString('base64'), mimeType } }];
> = [
{ inlineData: { data: imageBuffer.toString('base64'), mimeType } },
{ text: RECIPE_EXTRACTION_USER_PROMPT }
];
if (appendUserNote) parts.push({ text: appendUserNote });
const result = await withTimeout(
@@ -114,6 +118,7 @@ export async function extractRecipeFromImage(
imageBuffer: Buffer,
mimeType: string
): Promise<ExtractionResponse> {
let firstMsg: string | null = null;
try {
return await callGemini(imageBuffer, mimeType);
} catch (e) {
@@ -132,6 +137,9 @@ export async function extractRecipeFromImage(
: 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(
@@ -140,11 +148,23 @@ export async function extractRecipeFromImage(
'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
);
} catch (retryErr) {
if (retryErr instanceof GeminiError) throw 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', String(retryErr));
throw new GeminiError(
'AI_FAILED',
`retry failed: ${retryMsg} (first: ${firstMsg})`
);
}
}
}

View File

@@ -1,18 +1,27 @@
import { z } from 'zod';
import { SchemaType } from '@google/generative-ai';
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent.
Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück.
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.
Regeln:
- Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array).
- Zutaten: quantity als Zahl (Bruchteile wie ½, ¼, 1 ½ als Dezimalzahl 0.5, 0.25, 1.5), unit separat
(g, ml, l, kg, EL, TL, Stück, Prise, Msp, …).
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.
- Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60.
- Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst.
- Denke dir NICHTS dazu aus. Was nicht auf dem Bild steht, ist null.
- Antworte ausschließlich im vorgegebenen JSON-Schema. Kein Markdown, kein Prosa-Text.`;
- 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.

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,
query: string,
limit = 30,
offset = 0,
domains: string[] = []
offset = 0
): SearchHit[] {
const fts = buildFtsQuery(query);
if (!fts) return [];
// 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,
r.title,
r.description,
@@ -49,13 +46,9 @@ export function searchLocal(
FROM recipe r
JOIN recipe_fts f ON f.rowid = r.id
WHERE recipe_fts MATCH ?
${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''}
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
LIMIT ? OFFSET ?`;
const params = hasFilter
? [fts, ...domains, limit, offset]
: [fts, limit, offset];
return db.prepare(sql).all(...params) as SearchHit[];
return db.prepare(sql).all(fts, limit, offset) as SearchHit[];
}
export function listRecentRecipes(
@@ -95,18 +88,44 @@ export function listAllRecipes(db: Database.Database): SearchHit[] {
.all() as SearchHit[];
}
export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created';
export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
export function listAllRecipesPaginated(
db: Database.Database,
sort: AllRecipesSort,
limit: number,
offset: number
offset: number,
profileId: number | null = null
): 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
// zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und
// CASE ist überall zuverlässig.
const orderBy: Record<AllRecipesSort, string> = {
const orderBy: Record<Exclude<AllRecipesSort, 'viewed'>, string> = {
name: 'r.title COLLATE NOCASE ASC',
rating:
'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',
created: 'r.created_at DESC, r.id DESC'
};
// Without profile, 'viewed' degrades to alphabetical.
const effectiveSort = sort === 'viewed' ? 'name' : sort;
return db
.prepare(
`SELECT r.id,
@@ -126,7 +147,7 @@ export function listAllRecipesPaginated(
(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
ORDER BY ${orderBy[sort]}
ORDER BY ${orderBy[effectiveSort]}
LIMIT ? OFFSET ?`
)
.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

@@ -17,7 +17,8 @@ export function resolveStrategy(req: RequestShape): CacheStrategy {
path === '/api/recipes/import' ||
path === '/api/recipes/preview' ||
path === '/api/recipes/extract-from-photo' ||
path.startsWith('/api/recipes/search/web')
path.startsWith('/api/recipes/search/web') ||
path.startsWith('/api/shopping-list')
) {
return 'network-only';
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto, afterNavigate } from '$app/navigation';
import { goto, afterNavigate, beforeNavigate } from '$app/navigation';
import {
Settings,
CookingPot,
@@ -9,10 +9,12 @@
Menu,
BookOpen,
ArrowLeft,
Camera
Camera,
ShoppingCart
} from 'lucide-svelte';
import { profileStore } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte';
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
import { pwaStore } from '$lib/client/pwa.svelte';
import { searchFilterStore } from '$lib/client/search-filter.svelte';
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
@@ -26,12 +28,13 @@
import { installPrompt } from '$lib/client/install-prompt.svelte';
import { registerServiceWorker } from '$lib/client/sw-register';
import { SearchStore } from '$lib/client/search.svelte';
import { recordScroll, restoreScroll } from '$lib/client/scroll-restore';
let { data, children } = $props();
const navStore = new SearchStore({
pageSize: 30,
filterParam: () => {
webFilterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
@@ -87,7 +90,19 @@
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();
navOpen = false;
menuOpen = false;
@@ -96,11 +111,14 @@
// auf einem anderen Gerät oder in einem anderen Tab etwas geändert
// wurde.
void wishlistStore.refresh();
void shoppingCartStore.refresh();
restoreScroll(nav.type, nav.to?.url);
});
onMount(() => {
profileStore.load();
void wishlistStore.refresh();
void shoppingCartStore.refresh();
void searchFilterStore.load();
void pwaStore.init();
network.init();
@@ -128,9 +146,9 @@
<span class="version" title="Deployment-Tag">{data.version}</span>
</div>
{: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} />
</a>
</button>
{/if}
{#if showHeaderSearch}
<div class="nav-search-wrap" bind:this={navContainer}>
@@ -268,6 +286,16 @@
<span class="badge">{wishlistStore.count}</span>
{/if}
</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}>
<button
class="nav-link"
@@ -361,10 +389,15 @@
justify-content: center;
width: 40px;
height: 40px;
border: 0;
border-radius: var(--pill-radius);
background: transparent;
color: #2b6a3d;
text-decoration: none;
flex-shrink: 0;
cursor: pointer;
font: inherit;
padding: 0;
}
.home-back:hover {
background: #f4f8f5;

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount, tick, untrack } from 'svelte';
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 { SearchHit } from '$lib/server/recipes/search-local';
import { randomQuote } from '$lib/quotes';
@@ -16,7 +17,7 @@
const store = new SearchStore({
pageSize: LOCAL_PAGE,
filterParam: () => {
webFilterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
@@ -27,13 +28,20 @@
let favorites = $state<SearchHit[]>([]);
const ALL_PAGE = 10;
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
const ALL_SORTS: { value: AllSort; label: string }[] = [
{ value: 'name', label: 'Name' },
{ value: 'rating', label: 'Bewertung' },
{ 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 allSort = $state<AllSort>('name');
let allExhausted = $state(false);
@@ -42,11 +50,68 @@
let allChips: HTMLElement | undefined = $state();
let allObserver: IntersectionObserver | null = null;
export const snapshot: Snapshot<SearchSnapshot> = {
capture: () => store.captureSnapshot(),
restore: (s) => store.restoreSnapshot(s)
type CollapseKey = 'favorites' | 'recent';
const COLLAPSE_STORAGE_KEY = 'kochwas.collapsed.sections';
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() {
const res = await fetch('/api/recipes/search');
const body = await res.json();
@@ -57,9 +122,7 @@
if (allLoading || allExhausted) return;
allLoading = true;
try {
const res = await fetch(
`/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}`
);
const res = await fetch(buildAllUrl(allSort, ALL_PAGE, allRecipes.length));
if (!res.ok) return;
const body = await res.json();
const more = body.hits as SearchHit[];
@@ -83,9 +146,7 @@
const chipsBefore = allChips?.getBoundingClientRect().top ?? 0;
allLoading = true;
try {
const res = await fetch(
`/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
);
const res = await fetch(buildAllUrl(next, ALL_PAGE, 0));
if (!res.ok) return;
const body = await res.json();
const hits = body.hits as SearchHit[];
@@ -121,10 +182,24 @@
void loadRecent();
void searchFilterStore.load();
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;
}
// 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();
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.
@@ -156,6 +231,37 @@
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,
// without spamming the history stack. Pushing a new entry happens only
// when the user clicks a result or otherwise navigates away.
@@ -299,57 +405,91 @@
{:else}
{#if profileStore.active && favorites.length > 0}
<section class="listing">
<h2>Deine Favoriten</h2>
<ul class="cards">
{#each favorites as r (r.id)}
<li class="card-wrap">
<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>
<button
type="button"
class="section-head"
onclick={() => toggleCollapsed('favorites')}
aria-expanded={!collapsed.favorites}
>
<ChevronDown
size={18}
strokeWidth={2.2}
class={collapsed.favorites ? 'chev rotated' : 'chev'}
/>
<h2>Deine Favoriten</h2>
<span class="count">{favorites.length}</span>
</button>
{#if !collapsed.favorites}
<div transition:slide={{ duration: 180 }}>
<ul class="cards">
{#each favorites as r (r.id)}
<li class="card-wrap">
<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>
{/if}
{#if recent.length > 0}
<section class="listing">
<h2>Zuletzt hinzugefügt</h2>
<ul class="cards">
{#each recent as r (r.id)}
<li class="card-wrap">
<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>
<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>
<button
type="button"
class="section-head"
onclick={() => toggleCollapsed('recent')}
aria-expanded={!collapsed.recent}
>
<ChevronDown
size={18}
strokeWidth={2.2}
class={collapsed.recent ? 'chev rotated' : 'chev'}
/>
<h2>Zuletzt hinzugefügt</h2>
<span class="count">{recent.length}</span>
</button>
{#if !collapsed.recent}
<div transition:slide={{ duration: 180 }}>
<ul class="cards">
{#each recent as r (r.id)}
<li class="card-wrap">
<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>
<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>
{/if}
<section class="listing">
@@ -469,6 +609,49 @@
color: #444;
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 {
display: flex;
align-items: center;

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
} 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 }) => {
const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort;
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 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 });
};

View File

@@ -6,7 +6,11 @@ import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
import { createRateLimiter } from '$lib/server/ai/rate-limit';
import type { Ingredient, Step } from '$lib/types';
const MAX_BYTES = 8 * 1024 * 1024;
// 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',
@@ -41,16 +45,38 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
);
}
// 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 {
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.');
} 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,
@@ -95,9 +121,11 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
: e.code === 'AI_NOT_CONFIGURED'
? 503
: 503;
// Nur Code + Meta loggen, niemals Prompt/Response-Inhalt.
// 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`
`[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.');
}

View File

@@ -7,13 +7,9 @@ export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q')?.trim() ?? '';
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
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 =
q.length >= 1
? searchLocal(getDb(), q, limit, offset, domains)
? searchLocal(getDb(), q, limit, offset)
: offset === 0
? 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

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import {
Camera,
ImageUp,
Loader2,
Wand2,
AlertTriangle,
@@ -17,6 +18,7 @@
const store = new PhotoUploadStore();
let saving = $state(false);
let cameraInput = $state<HTMLInputElement | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
function onPick(e: Event) {
@@ -85,20 +87,42 @@
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
scharf, gut ausgeleuchtet.
</p>
<button
type="button"
class="btn primary"
onclick={() => fileInput?.click()}
disabled={!network.online}
>
<Camera size={18} strokeWidth={2} />
<span>Foto wählen oder aufnehmen</span>
</button>
<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/*"
capture="environment"
hidden
onchange={onPick}
/>

View File

@@ -355,9 +355,28 @@
}
};
document.addEventListener('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(() => {
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">
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 { wishlistStore } from '$lib/client/wishlist.svelte';
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
import { confirmAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
@@ -70,9 +71,19 @@
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(() => {
void load();
void wishlistStore.refresh();
void shoppingCartStore.refresh();
});
function resolveImage(p: string | null): string | null {
@@ -125,16 +136,13 @@
{#if e.wanted_by_names}
<span class="wanted-by">{e.wanted_by_names}</span>
{/if}
{#if e.source_domain}
<span class="src">· {e.source_domain}</span>
{/if}
{#if e.avg_stars !== null}
<span>· ★ {e.avg_stars.toFixed(1)}</span>
{/if}
</div>
</div>
</a>
<div class="actions">
<div class="actions-top">
<button
class="like"
class:active={e.on_my_wishlist}
@@ -146,6 +154,16 @@
<span class="count">{e.wanted_by_count}</span>
{/if}
</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
class="del"
aria-label="Für alle entfernen"
@@ -227,6 +245,7 @@
gap: 0.75rem;
}
.card {
position: relative;
display: flex;
align-items: stretch;
background: white;
@@ -255,7 +274,7 @@
}
.text {
flex: 1;
padding: 0.7rem 0.75rem;
padding: 0.7rem 170px 0.7rem 0.75rem;
min-width: 0;
display: flex;
flex-direction: column;
@@ -265,6 +284,8 @@
font-weight: 600;
font-size: 1rem;
line-height: 1.3;
overflow-wrap: break-word;
word-break: break-word;
}
.meta {
display: flex;
@@ -278,18 +299,19 @@
color: #2b6a3d;
font-weight: 500;
}
.actions {
.actions-top {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: stretch;
justify-content: center;
padding: 0.5rem 0.6rem 0.5rem 0;
z-index: 1;
}
.like,
.cart,
.del {
min-width: 48px;
min-height: 40px;
min-width: 44px;
min-height: 44px;
border-radius: 10px;
border: 1px solid #e4eae7;
background: white;
@@ -297,8 +319,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
font-size: 1.05rem;
gap: 0.25rem;
font-size: 1rem;
color: #444;
}
.like.active {
@@ -306,6 +328,11 @@
background: #eaf4ed;
border-color: #b7d6c2;
}
.cart.active {
color: #2b6a3d;
background: #eaf4ed;
border-color: #b7d6c2;
}
.del:hover {
color: #c53030;
border-color: #f1b4b4;
@@ -315,4 +342,51 @@
font-size: 0.85rem;
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>

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,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);
});
});

View File

@@ -70,8 +70,8 @@ describe('POST /api/recipes/extract-from-photo', () => {
expect(body.recipe.id).toBeNull();
});
it('413 when file exceeds 8 MB', async () => {
const big = Buffer.alloc(9 * 1024 * 1024);
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

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

@@ -69,20 +69,11 @@ describe('searchLocal', () => {
expect(searchLocal(db, ' ')).toEqual([]);
});
it('filters by domain when supplied', () => {
it('ignores source_domain — local search is domain-agnostic', () => {
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, ['chefkoch.de']);
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, []);
const hits = searchLocal(db, 'apfel');
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

@@ -30,6 +30,12 @@ describe('resolveStrategy', () => {
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', () => {
expect(resolveStrategy({ url: '/_app/immutable/chunks/x.js', method: 'GET' })).toBe('shell');
expect(resolveStrategy({ url: '/icon-192.png', method: 'GET' })).toBe('shell');

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,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);
});
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();
const fetchImpl = mockFetch([
{ body: { hits: [] } },
@@ -211,13 +211,15 @@ describe('SearchStore', () => {
const store = new SearchStore({
fetchImpl,
debounceMs: 10,
filterParam: () => '&domains=chefkoch.de'
webFilterParam: () => '&domains=chefkoch.de'
});
store.query = 'curry';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/);
expect(fetchImpl.mock.calls[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/);
});
@@ -243,22 +245,25 @@ describe('SearchStore', () => {
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ body: { hits: [] } },
{ body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
{ body: { hits: [] } },
{ body: { hits: [{ url: 'https://chefkoch.de/x', title: 'filtered', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } }
]);
const store = new SearchStore({
fetchImpl,
debounceMs: 10,
filterDebounceMs: 5,
filterParam: () => filter
webFilterParam: () => filter
});
store.query = 'broth';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
filter = '&domains=chefkoch.de';
store.reSearch();
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;
expect(last).toMatch(/\/api\/recipes\/search\/web\?/);
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' });
});
});