10 Commits

Author SHA1 Message Date
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
15 changed files with 2728 additions and 73 deletions

View File

@@ -17,9 +17,10 @@ services:
- GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000} - GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
# adapter-node-Default ist 512 KB; Rezept-Fotos koennen bis 8 MB sein. # adapter-node-Default ist 512 KB. Tablet- und iPad-Pro-Kameras liefern
# Multipart-Overhead einrechnen -> 10 MB gibt etwas Puffer. # JPEGs/HEICs bis 15 MB. Endpoint-Limit ist 20 MB; hier 25 MB fuer den
- BODY_SIZE_LIMIT=10000000 # Multipart-Overhead.
- BODY_SIZE_LIMIT=25000000
depends_on: depends_on:
- searxng - searxng
restart: unless-stopped restart: unless-stopped

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

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

View File

@@ -2,6 +2,7 @@ import { GoogleGenerativeAI } from '@google/generative-ai';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { import {
RECIPE_EXTRACTION_SYSTEM_PROMPT, RECIPE_EXTRACTION_SYSTEM_PROMPT,
RECIPE_EXTRACTION_USER_PROMPT,
GEMINI_RESPONSE_SCHEMA, GEMINI_RESPONSE_SCHEMA,
extractionResponseSchema, extractionResponseSchema,
type ExtractionResponse type ExtractionResponse
@@ -84,7 +85,10 @@ async function callGemini(
const parts: Array< const parts: Array<
{ inlineData: { data: string; mimeType: string } } | { text: string } { 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 }); if (appendUserNote) parts.push({ text: appendUserNote });
const result = await withTimeout( const result = await withTimeout(
@@ -114,6 +118,7 @@ export async function extractRecipeFromImage(
imageBuffer: Buffer, imageBuffer: Buffer,
mimeType: string mimeType: string
): Promise<ExtractionResponse> { ): Promise<ExtractionResponse> {
let firstMsg: string | null = null;
try { try {
return await callGemini(imageBuffer, mimeType); return await callGemini(imageBuffer, mimeType);
} catch (e) { } catch (e) {
@@ -132,6 +137,9 @@ export async function extractRecipeFromImage(
: new GeminiError('AI_FAILED', String(e)); : new GeminiError('AI_FAILED', String(e));
} }
firstMsg = e instanceof Error ? e.message : String(e);
console.warn(`[gemini-client] first attempt failed, retrying: ${firstMsg}`);
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
try { try {
return await callGemini( 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.' 'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
); );
} catch (retryErr) { } 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); const retryStatus = getStatus(retryErr);
if (retryStatus === 429) if (retryStatus === 429)
throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry'); 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 { z } from 'zod';
import { SchemaType } from '@google/generative-ai'; import { SchemaType } from '@google/generative-ai';
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent. 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.
Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück.
Regeln: SPRACHE:
- Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array). - 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.
- 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, …). 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. - Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt.
- Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60. - Zeit: Alle Angaben strikt in Minuten (Integer). "1 Stunde" = 60.
- Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst. - Rauschen ignorieren: Keine Werbung, Einleitungstexte oder Bildunterschriften extrahieren.
- 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.`; 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 // Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent
// übergeben; Gemini respektiert die Struktur und liefert valides JSON. // übergeben; Gemini respektiert die Struktur und liefert valides JSON.

View File

@@ -30,15 +30,12 @@ export function searchLocal(
db: Database.Database, db: Database.Database,
query: string, query: string,
limit = 30, limit = 30,
offset = 0, offset = 0
domains: string[] = []
): SearchHit[] { ): SearchHit[] {
const fts = buildFtsQuery(query); const fts = buildFtsQuery(query);
if (!fts) return []; if (!fts) return [];
// bm25: lower is better. Use weights: title > tags > ingredients > description // bm25: lower is better. Use weights: title > tags > ingredients > description
const hasFilter = domains.length > 0;
const placeholders = hasFilter ? domains.map(() => '?').join(',') : '';
const sql = `SELECT r.id, const sql = `SELECT r.id,
r.title, r.title,
r.description, r.description,
@@ -49,13 +46,9 @@ export function searchLocal(
FROM recipe r FROM recipe r
JOIN recipe_fts f ON f.rowid = r.id JOIN recipe_fts f ON f.rowid = r.id
WHERE recipe_fts MATCH ? WHERE recipe_fts MATCH ?
${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''}
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0) ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
LIMIT ? OFFSET ?`; LIMIT ? OFFSET ?`;
const params = hasFilter return db.prepare(sql).all(fts, limit, offset) as SearchHit[];
? [fts, ...domains, limit, offset]
: [fts, limit, offset];
return db.prepare(sql).all(...params) as SearchHit[];
} }
export function listRecentRecipes( export function listRecentRecipes(

View File

@@ -31,7 +31,7 @@
const navStore = new SearchStore({ const navStore = new SearchStore({
pageSize: 30, pageSize: 30,
filterParam: () => { webFilterParam: () => {
const p = searchFilterStore.queryParam; const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : ''; return p ? `&domains=${encodeURIComponent(p)}` : '';
} }

View File

@@ -16,7 +16,7 @@
const store = new SearchStore({ const store = new SearchStore({
pageSize: LOCAL_PAGE, pageSize: LOCAL_PAGE,
filterParam: () => { webFilterParam: () => {
const p = searchFilterStore.queryParam; const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : ''; return p ? `&domains=${encodeURIComponent(p)}` : '';
} }

View File

@@ -6,7 +6,11 @@ import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
import { createRateLimiter } from '$lib/server/ai/rate-limit'; import { createRateLimiter } from '$lib/server/ai/rate-limit';
import type { Ingredient, Step } from '$lib/types'; 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([ const ALLOWED_MIME = new Set([
'image/jpeg', 'image/jpeg',
'image/png', '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; let form: FormData;
try { try {
form = await request.formData(); form = await request.formData();
} catch { } catch (e) {
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.'); 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'); const photo = form.get('photo');
if (!(photo instanceof Blob)) { 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.'); 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) { if (photo.size > MAX_BYTES) {
return errJson( return errJson(
413, 413,
@@ -95,9 +121,11 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
: e.code === 'AI_NOT_CONFIGURED' : e.code === 'AI_NOT_CONFIGURED'
? 503 ? 503
: 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( 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.'); 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 q = url.searchParams.get('q')?.trim() ?? '';
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100); const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0)); const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
const domains = (url.searchParams.get('domains') ?? '')
.split(',')
.map((d) => d.trim())
.filter(Boolean);
const hits = const hits =
q.length >= 1 q.length >= 1
? searchLocal(getDb(), q, limit, offset, domains) ? searchLocal(getDb(), q, limit, offset)
: offset === 0 : offset === 0
? listRecentRecipes(getDb(), limit) ? listRecentRecipes(getDb(), limit)
: []; : [];

View File

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

View File

@@ -70,8 +70,8 @@ describe('POST /api/recipes/extract-from-photo', () => {
expect(body.recipe.id).toBeNull(); expect(body.recipe.id).toBeNull();
}); });
it('413 when file exceeds 8 MB', async () => { it('413 when file exceeds 20 MB', async () => {
const big = Buffer.alloc(9 * 1024 * 1024); const big = Buffer.alloc(21 * 1024 * 1024);
const fd = new FormData(); const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' })); fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

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

View File

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