# 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 `