diff --git a/docs/superpowers/plans/2026-04-21-shopping-list.md b/docs/superpowers/plans/2026-04-21-shopping-list.md new file mode 100644 index 0000000..7e8ba11 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-shopping-list.md @@ -0,0 +1,2293 @@ +# Einkaufsliste 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:** Eine haushaltsweit geteilte Einkaufsliste, die Rezepte aus der Wunschliste aufnimmt, deren Zutaten aggregiert anzeigt, und beim Einkaufen abgehakt werden kann. + +**Architecture:** Neue Tabellen `shopping_cart_recipe` + `shopping_cart_check`. Aggregation wird bei jedem Read aus `shopping_cart_recipe JOIN ingredient × servings-Faktor` derived — nichts materialisiert. Abhaken pro `(name_key, unit_key)`. Client-Store analog `wishlistStore` hält `uncheckedCount` + `recipeIds` fürs Header-Badge. + +**Tech Stack:** SvelteKit, better-sqlite3, Vite migrations via `import.meta.glob`, Vitest, Playwright (E2E nach Deploy), Zod für Body-Validation, Lucide-Icons (`ShoppingCart`). + +**Spec:** `docs/superpowers/specs/2026-04-21-shopping-list-design.md` + +--- + +## Konventionen + +**Nach jedem Task:** +- `npm test` muss grün sein +- `npm run check` muss 0 Errors / 0 Warnings zeigen +- Commit folgt Projekt-Konvention (siehe CLAUDE.md): englische Subject-Zeile < 72 Zeichen, deutscher Body, kein `--no-verify` +- Push nach jedem Commit (CI baut arm64-Image) + +**Commit-Prefix:** `feat(shopping)` für neue Funktionalität, `test(shopping)` für reine Test-Commits, `refactor(wishlist)` für das Karten-Relayout, `chore(db)` für die Migration. + +--- + +## File Structure + +**Create:** +- `src/lib/server/db/migrations/013_shopping_list.sql` +- `src/lib/server/shopping/repository.ts` +- `src/lib/quantity-format.ts` +- `src/lib/client/shopping-cart.svelte.ts` +- `src/routes/api/shopping-list/+server.ts` +- `src/routes/api/shopping-list/recipe/+server.ts` +- `src/routes/api/shopping-list/recipe/[recipe_id]/+server.ts` +- `src/routes/api/shopping-list/check/+server.ts` +- `src/routes/api/shopping-list/checked/+server.ts` +- `src/routes/shopping-list/+page.svelte` +- `src/lib/components/ShoppingCartChip.svelte` +- `src/lib/components/ShoppingListRow.svelte` +- `tests/integration/shopping-repository.test.ts` +- `tests/unit/shopping-cart-store.test.ts` +- `tests/unit/quantity-format.test.ts` +- `tests/e2e/remote/shopping.spec.ts` + +**Modify:** +- `src/routes/+layout.svelte` — ShoppingCart-Icon + Badge +- `src/routes/wishlist/+page.svelte` — horizontale Action-Leiste, Cart-Button, Domain raus +- `src/lib/sw/cache-strategy.ts` — `/api/shopping-list/*` als network-only + +--- + +## Phase 1 — Datenbank + +### Task 1: Migration 013 + +**Files:** +- Create: `src/lib/server/db/migrations/013_shopping_list.sql` + +- [ ] **Step 1: Migration-Datei schreiben** + +```sql +-- 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) +); +``` + +- [ ] **Step 2: Tests laufen lassen — Migration wird vom Bundler aufgenommen** + +Run: `npm test` +Expected: Alle bestehenden Tests grün. `openInMemoryForTest` wendet die neue Migration auto. an. + +- [ ] **Step 3: svelte-check** + +Run: `npm run check` +Expected: 0 ERRORS 0 WARNINGS + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/db/migrations/013_shopping_list.sql +git commit -m "chore(db): Migration 013 fuer Einkaufsliste-Tabellen" +git push +``` + +--- + +## Phase 2 — Repository (TDD) + +### Task 2: Repository-Skeleton + Types + +**Files:** +- Create: `src/lib/server/shopping/repository.ts` + +- [ ] **Step 1: Skeleton mit Types und noch nicht implementierten Funktionen** + +```ts +import type Database from 'better-sqlite3'; + +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 { + throw new Error('not implemented'); +} + +export function removeRecipeFromCart( + _db: Database.Database, + _recipeId: number +): void { + throw new Error('not implemented'); +} + +export function setCartServings( + _db: Database.Database, + _recipeId: number, + _servings: number +): void { + throw new Error('not implemented'); +} + +export function listShoppingList( + _db: Database.Database +): ShoppingListSnapshot { + throw new Error('not implemented'); +} + +export function toggleCheck( + _db: Database.Database, + _nameKey: string, + _unitKey: string, + _checked: boolean +): void { + throw new Error('not implemented'); +} + +export function clearCheckedItems(_db: Database.Database): void { + throw new Error('not implemented'); +} + +export function clearCart(_db: Database.Database): void { + throw new Error('not implemented'); +} +``` + +- [ ] **Step 2: svelte-check** + +Run: `npm run check` +Expected: 0 ERRORS 0 WARNINGS + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/shopping/repository.ts +git commit -m "feat(shopping): Repository-Skeleton mit Types" +git push +``` + +--- + +### Task 3: addRecipeToCart (idempotent) + +**Files:** +- Create: `tests/integration/shopping-repository.test.ts` +- Modify: `src/lib/server/shopping/repository.ts` + +- [ ] **Step 1: Test schreiben** + +```ts +import { describe, it, expect } from 'vitest'; +import { openInMemoryForTest } from '../../src/lib/server/db'; +import { insertRecipe } from '../../src/lib/server/recipes/repository'; +import { + addRecipeToCart, + listShoppingList +} from '../../src/lib/server/shopping/repository'; +import type { Recipe } from '../../src/lib/types'; + +function recipe(overrides: Partial = {}): 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); + }); +}); +``` + +- [ ] **Step 2: Test läuft — muss FAILEN (Funktion wirft 'not implemented')** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` +Expected: alle 4 Tests FAIL (throw 'not implemented' oder listShoppingList not implemented) + +- [ ] **Step 3: Minimal implementieren** + +`addRecipeToCart` + `listShoppingList`-Skelett (nur Recipes, Rows kommt später): + +```ts +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 ?? 4; + 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 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[]; + return { recipes, rows: [], uncheckedCount: 0 }; +} +``` + +- [ ] **Step 4: Test grün?** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` +Expected: 4/4 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts +git commit -m "feat(shopping): addRecipeToCart (idempotent via ON CONFLICT)" +git push +``` + +--- + +### Task 4: removeRecipeFromCart + +**Files:** +- Modify: `src/lib/server/shopping/repository.ts` +- Modify: `tests/integration/shopping-repository.test.ts` + +- [ ] **Step 1: Test schreiben (neuer describe-Block ans Ende der Datei)** + +```ts +import { + addRecipeToCart, + removeRecipeFromCart, + listShoppingList +} from '../../src/lib/server/shopping/repository'; + +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(); + }); +}); +``` + +- [ ] **Step 2: Test muss failen** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` +Expected: die neuen 2 Tests FAIL ('not implemented') + +- [ ] **Step 3: Implementieren** + +```ts +export function removeRecipeFromCart( + db: Database.Database, + recipeId: number +): void { + db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(recipeId); +} +``` + +- [ ] **Step 4: Tests grün?** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` +Expected: alle Tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts +git commit -m "feat(shopping): removeRecipeFromCart" +git push +``` + +--- + +### Task 5: setCartServings + +**Files:** +- Modify: `src/lib/server/shopping/repository.ts` +- Modify: `tests/integration/shopping-repository.test.ts` + +- [ ] **Step 1: Test schreiben** + +```ts +import { setCartServings } from '../../src/lib/server/shopping/repository'; + +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(); + }); +}); +``` + +- [ ] **Step 2: Test muss failen** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` +Expected: neue Tests FAIL + +- [ ] **Step 3: Implementieren** + +```ts +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); +} +``` + +- [ ] **Step 4: Tests grün?** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts +git commit -m "feat(shopping): setCartServings mit Positiv-Validation" +git push +``` + +--- + +### Task 6: listShoppingList mit Aggregation + +**Files:** +- Modify: `src/lib/server/shopping/repository.ts` +- Modify: `tests/integration/shopping-repository.test.ts` + +- [ ] **Step 1: Tests schreiben für Aggregation** + +```ts +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('g'); + 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); + }); +}); +``` + +- [ ] **Step 2: Tests laufen — müssen failen** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` +Expected: die 5 neuen Tests FAIL + +- [ ] **Step 3: listShoppingList ausbauen** + +```ts +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[]; + + const rows = 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 / COALESCE(r.servings_default, cr.servings)) 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` + ) + .all() as ShoppingListRow[]; + + const uncheckedCount = rows.reduce((n, r) => n + (r.checked ? 0 : 1), 0); + return { recipes, rows, uncheckedCount }; +} +``` + +- [ ] **Step 4: Tests grün?** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts +git commit -m "feat(shopping): listShoppingList mit Aggregation + Skalierung" +git push +``` + +--- + +### Task 7: toggleCheck + +**Files:** +- Modify: `src/lib/server/shopping/repository.ts` +- Modify: `tests/integration/shopping-repository.test.ts` + +- [ ] **Step 1: Tests schreiben** + +```ts +import { toggleCheck } from '../../src/lib/server/shopping/repository'; + +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', 'g', 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', 'g', true); + toggleCheck(db, 'mehl', 'g', 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', 'g', 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); + }); +}); +``` + +- [ ] **Step 2: Tests laufen — müssen failen** + +Run: `npx vitest run tests/integration/shopping-repository.test.ts` + +- [ ] **Step 3: Implementieren** + +```ts +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); + } +} +``` + +- [ ] **Step 4: Tests grün?** + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts +git commit -m "feat(shopping): toggleCheck (idempotent)" +git push +``` + +--- + +### Task 8: clearCheckedItems + +**Files:** +- Modify: `src/lib/server/shopping/repository.ts` +- Modify: `tests/integration/shopping-repository.test.ts` + +- [ ] **Step 1: Tests schreiben** + +```ts +import { clearCheckedItems } from '../../src/lib/server/shopping/repository'; + +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); + }); +}); +``` + +- [ ] **Step 2: Tests müssen failen** + +- [ ] **Step 3: Implementieren** + +```ts +export function clearCheckedItems(db: Database.Database): void { + const tx = db.transaction(() => { + // Alle aggregierten Zeilen mit checked-Status holen, pro recipe_id gruppieren + // und Rezepte finden, deren Zeilen ALLE abgehakt sind. + 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 }[]; + + const perRecipe = new Map(); + 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: 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); + } + } + }); + tx(); +} +``` + +- [ ] **Step 4: Tests grün?** + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts +git commit -m "feat(shopping): clearCheckedItems + Orphan-Cleanup" +git push +``` + +--- + +### Task 9: clearCart + +**Files:** +- Modify: `src/lib/server/shopping/repository.ts` +- Modify: `tests/integration/shopping-repository.test.ts` + +- [ ] **Step 1: Test schreiben** + +```ts +import { clearCart } from '../../src/lib/server/shopping/repository'; + +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(); + }); +}); +``` + +- [ ] **Step 2: Test muss failen** + +- [ ] **Step 3: Implementieren** + +```ts +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(); +} +``` + +- [ ] **Step 4: Tests grün?** + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts +git commit -m "feat(shopping): clearCart" +git push +``` + +--- + +## Phase 3 — Quantity-Formatter + +### Task 10: formatQuantity + Tests + +**Files:** +- Create: `src/lib/quantity-format.ts` +- Create: `tests/unit/quantity-format.test.ts` + +- [ ] **Step 1: Tests schreiben** + +```ts +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.10)).toBe('1.1'); + }); + + it('handles zero', () => { + expect(formatQuantity(0)).toBe('0'); + }); +}); +``` + +- [ ] **Step 2: Test muss failen (Modul existiert nicht)** + +Run: `npx vitest run tests/unit/quantity-format.test.ts` + +- [ ] **Step 3: Implementieren** + +```ts +export function formatQuantity(q: number | null): string { + if (q === null || q === undefined) return ''; + const rounded = Math.round(q); + if (Math.abs(q - rounded) < 0.01) return String(rounded); + // auf max. 2 Nachkommastellen, trailing Nullen raus + return q + .toFixed(2) + .replace(/\.?0+$/, ''); +} +``` + +- [ ] **Step 4: Tests grün?** + +Run: `npx vitest run tests/unit/quantity-format.test.ts` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/quantity-format.ts tests/unit/quantity-format.test.ts +git commit -m "feat(shopping): formatQuantity-Utility" +git push +``` + +--- + +## Phase 4 — API-Routen + +### Task 11: GET /api/shopping-list + DELETE (Liste leeren) + +**Files:** +- Create: `src/routes/api/shopping-list/+server.ts` + +- [ ] **Step 1: Endpoint implementieren** + +```ts +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 }); +}; +``` + +- [ ] **Step 2: svelte-check** + +Run: `npm run check` +Expected: 0/0 + +- [ ] **Step 3: Tests laufen (bestehende Tests bleiben grün, neuer Endpoint unbenutzt)** + +Run: `npm test` + +- [ ] **Step 4: Commit** + +```bash +git add src/routes/api/shopping-list/+server.ts +git commit -m "feat(shopping): GET /api/shopping-list + DELETE (Liste leeren)" +git push +``` + +--- + +### Task 12: POST /api/shopping-list/recipe + +**Files:** +- Create: `src/routes/api/shopping-list/recipe/+server.ts` + +- [ ] **Step 1: Implementieren** + +```ts +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 }); +}; +``` + +- [ ] **Step 2: svelte-check + tests** + +Run: `npm run check && npm test` + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/api/shopping-list/recipe/+server.ts +git commit -m "feat(shopping): POST /api/shopping-list/recipe" +git push +``` + +--- + +### Task 13: PATCH/DELETE /api/shopping-list/recipe/[recipe_id] + +**Files:** +- Create: `src/routes/api/shopping-list/recipe/[recipe_id]/+server.ts` + +- [ ] **Step 1: Implementieren** + +```ts +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 }); +}; +``` + +- [ ] **Step 2: svelte-check + tests** + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/api/shopping-list/recipe/\[recipe_id\]/+server.ts +git commit -m "feat(shopping): PATCH/DELETE /api/shopping-list/recipe/[id]" +git push +``` + +--- + +### Task 14: POST/DELETE /api/shopping-list/check + +**Files:** +- Create: `src/routes/api/shopping-list/check/+server.ts` + +- [ ] **Step 1: Implementieren** + +```ts +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 }); +}; +``` + +- [ ] **Step 2: svelte-check + tests** + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/api/shopping-list/check/+server.ts +git commit -m "feat(shopping): POST/DELETE /api/shopping-list/check" +git push +``` + +--- + +### Task 15: DELETE /api/shopping-list/checked + +**Files:** +- Create: `src/routes/api/shopping-list/checked/+server.ts` + +- [ ] **Step 1: Implementieren** + +```ts +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 }); +}; +``` + +- [ ] **Step 2: svelte-check + tests** + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/api/shopping-list/checked/+server.ts +git commit -m "feat(shopping): DELETE /api/shopping-list/checked (Erledigte entfernen)" +git push +``` + +--- + +## Phase 5 — Service-Worker + +### Task 16: network-only für /api/shopping-list/* + +**Files:** +- Modify: `src/lib/sw/cache-strategy.ts` +- Modify: `tests/unit/cache-strategy.test.ts` + +- [ ] **Step 1: Test hinzufügen (ans Ende der bestehenden Datei)** + +```ts +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'); +}); +``` + +- [ ] **Step 2: Test muss failen** + +Run: `npx vitest run tests/unit/cache-strategy.test.ts` + +- [ ] **Step 3: cache-strategy.ts erweitern** + +In `src/lib/sw/cache-strategy.ts`, erweitere den „Explicitly online-only GETs"-Block: + +```ts + if ( + path === '/api/recipes/import' || + path === '/api/recipes/preview' || + path === '/api/recipes/extract-from-photo' || + path.startsWith('/api/recipes/search/web') || + path.startsWith('/api/shopping-list') + ) { + return 'network-only'; + } +``` + +- [ ] **Step 4: Tests grün?** + +Run: `npm test` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/sw/cache-strategy.ts tests/unit/cache-strategy.test.ts +git commit -m "feat(shopping): Service-Worker network-only fuer /api/shopping-list/*" +git push +``` + +--- + +## Phase 6 — Client-Store + +### Task 17: ShoppingCartStore + Tests + +**Files:** +- Create: `src/lib/client/shopping-cart.svelte.ts` +- Create: `tests/unit/shopping-cart-store.test.ts` + +- [ ] **Step 1: Tests schreiben** + +```ts +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { ShoppingCartStore } from '../../src/lib/client/shopping-cart.svelte'; + +type FetchMock = ReturnType; + +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([ + {}, // POST response + 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([ + {}, // DELETE response + 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); + }); +}); +``` + +- [ ] **Step 2: Tests müssen failen** + +- [ ] **Step 3: Store implementieren** + +```ts +type Snapshot = { + recipes: { recipe_id: number }[]; + uncheckedCount: number; +}; + +export class ShoppingCartStore { + uncheckedCount = $state(0); + recipeIds = $state>(new Set()); + loaded = $state(false); + + private readonly fetchImpl: typeof fetch; + + constructor(fetchImpl?: typeof fetch) { + this.fetchImpl = fetchImpl ?? ((...a) => fetch(...a)); + } + + async refresh(): Promise { + 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 { + await this.fetchImpl('/api/shopping-list/recipe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ recipe_id: recipeId }) + }); + await this.refresh(); + } + + async removeRecipe(recipeId: number): Promise { + await this.fetchImpl(`/api/shopping-list/recipe/${recipeId}`, { method: 'DELETE' }); + await this.refresh(); + } + + isInCart(recipeId: number): boolean { + return this.recipeIds.has(recipeId); + } +} + +export const shoppingCartStore = new ShoppingCartStore(); +``` + +- [ ] **Step 4: Tests grün?** + +Run: `npx vitest run tests/unit/shopping-cart-store.test.ts` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/client/shopping-cart.svelte.ts tests/unit/shopping-cart-store.test.ts +git commit -m "feat(shopping): ShoppingCartStore (Client)" +git push +``` + +--- + +## Phase 7 — Header-Badge + +### Task 18: ShoppingCart-Icon im Header + +**Files:** +- Modify: `src/routes/+layout.svelte` + +- [ ] **Step 1: Icon-Import ergänzen** + +In `src/routes/+layout.svelte`, in der Lucide-Import-Liste (Zeile 5-13), `ShoppingCart` hinzufügen: + +```ts + import { + Settings, + CookingPot, + Utensils, + Menu, + BookOpen, + ArrowLeft, + Camera, + ShoppingCart + } from 'lucide-svelte'; +``` + +- [ ] **Step 2: Store importieren + onMount + afterNavigate erweitern** + +```ts + import { shoppingCartStore } from '$lib/client/shopping-cart.svelte'; +``` + +In `afterNavigate` nach `void wishlistStore.refresh();` ergänzen: +```ts + void shoppingCartStore.refresh(); +``` + +In `onMount` nach `void wishlistStore.refresh();`: +```ts + void shoppingCartStore.refresh(); +``` + +- [ ] **Step 3: Markup — neuer Link rechts vom CookingPot** + +Im `
`-Block, direkt **nach** dem ``-Ende des `wishlist-link` (nach Zeile 270), vor `