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>
66 KiB
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 testmuss grün seinnpm run checkmuss 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.sqlsrc/lib/server/shopping/repository.tssrc/lib/quantity-format.tssrc/lib/client/shopping-cart.svelte.tssrc/routes/api/shopping-list/+server.tssrc/routes/api/shopping-list/recipe/+server.tssrc/routes/api/shopping-list/recipe/[recipe_id]/+server.tssrc/routes/api/shopping-list/check/+server.tssrc/routes/api/shopping-list/checked/+server.tssrc/routes/shopping-list/+page.sveltesrc/lib/components/ShoppingCartChip.sveltesrc/lib/components/ShoppingListRow.sveltetests/integration/shopping-repository.test.tstests/unit/shopping-cart-store.test.tstests/unit/quantity-format.test.tstests/e2e/remote/shopping.spec.ts
Modify:
src/routes/+layout.svelte— ShoppingCart-Icon + Badgesrc/routes/wishlist/+page.svelte— horizontale Action-Leiste, Cart-Button, Domain raussrc/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
-- 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
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
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
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
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> = {}): 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):
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
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)
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
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
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
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
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
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
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
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
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
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
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
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
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
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<number, { total: number; checked: number }>();
for (const r of allRows) {
const e = perRecipe.get(r.recipe_id) ?? { total: 0, checked: 0 };
e.total += 1;
e.checked += r.checked;
perRecipe.set(r.recipe_id, e);
}
const toRemove: number[] = [];
for (const [id, e] of perRecipe) {
if (e.total > 0 && e.total === e.checked) toRemove.push(id);
}
for (const id of toRemove) {
db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(id);
}
// Orphan-Checks raeumen: 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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)
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:
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
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
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { ShoppingCartStore } from '../../src/lib/client/shopping-cart.svelte';
type FetchMock = ReturnType<typeof vi.fn>;
function snapshotBody(opts: {
recipeIds?: number[];
uncheckedCount?: number;
}) {
return {
recipes: (opts.recipeIds ?? []).map((id) => ({
recipe_id: id, title: `R${id}`, image_path: null, servings: 4, servings_default: 4
})),
rows: [],
uncheckedCount: opts.uncheckedCount ?? 0
};
}
function makeFetch(responses: unknown[]): FetchMock {
const queue = [...responses];
return vi.fn(async () => ({
ok: true,
status: 200,
json: async () => queue.shift()
} as Response));
}
describe('ShoppingCartStore', () => {
it('refresh populates recipeIds and uncheckedCount', async () => {
const fetchImpl = makeFetch([snapshotBody({ recipeIds: [1, 2], uncheckedCount: 3 })]);
const store = new ShoppingCartStore(fetchImpl);
await store.refresh();
expect(store.uncheckedCount).toBe(3);
expect(store.isInCart(1)).toBe(true);
expect(store.isInCart(2)).toBe(true);
expect(store.isInCart(3)).toBe(false);
expect(store.loaded).toBe(true);
});
it('addRecipe posts then refreshes', async () => {
const fetchImpl = makeFetch([
{}, // 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
type Snapshot = {
recipes: { recipe_id: number }[];
uncheckedCount: number;
};
export class ShoppingCartStore {
uncheckedCount = $state(0);
recipeIds = $state<Set<number>>(new Set());
loaded = $state(false);
private readonly fetchImpl: typeof fetch;
constructor(fetchImpl?: typeof fetch) {
this.fetchImpl = fetchImpl ?? ((...a) => fetch(...a));
}
async refresh(): Promise<void> {
try {
const res = await this.fetchImpl('/api/shopping-list');
if (!res.ok) return;
const body = (await res.json()) as Snapshot;
this.recipeIds = new Set(body.recipes.map((r) => r.recipe_id));
this.uncheckedCount = body.uncheckedCount;
this.loaded = true;
} catch {
// keep last known state on network error
}
}
async addRecipe(recipeId: number): Promise<void> {
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<void> {
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
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:
import {
Settings,
CookingPot,
Utensils,
Menu,
BookOpen,
ArrowLeft,
Camera,
ShoppingCart
} from 'lucide-svelte';
- Step 2: Store importieren + onMount + afterNavigate erweitern
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
In afterNavigate nach void wishlistStore.refresh(); ergänzen:
void shoppingCartStore.refresh();
In onMount nach void wishlistStore.refresh();:
void shoppingCartStore.refresh();
- Step 3: Markup — neuer Link rechts vom CookingPot
Im <header>-Block, direkt nach dem </a>-Ende des wishlist-link (nach Zeile 270), vor <div class="menu-wrap">, einfügen:
{#if shoppingCartStore.uncheckedCount > 0}
<a
href="/shopping-list"
class="nav-link shopping-link"
aria-label={`Einkaufsliste (${shoppingCartStore.uncheckedCount})`}
>
<ShoppingCart size={20} strokeWidth={2} />
<span class="badge">{shoppingCartStore.uncheckedCount}</span>
</a>
{/if}
.shopping-link kann die gleichen Styles wie .wishlist-link nutzen — falls nötig im <style>-Block ergänzen (Farbe: gleiches Grün #2b6a3d).
- Step 4: Manueller Test + svelte-check
Run: npm run dev, öffne http://localhost:5173, lege via POST /api/shopping-list/recipe ein Rezept in den Cart (oder warte bis der UI-Teil fertig ist und nutze die Wunschliste). Verify: Icon erscheint + Badge-Zahl stimmt.
Run: npm run check
- Step 5: Commit
git add src/routes/+layout.svelte
git commit -m "feat(shopping): Header-Badge mit Einkaufswagen-Icon"
git push
Phase 8 — Wunschlisten-Karte Relayout
Task 19: Horizontale Action-Leiste + Cart-Button, Domain raus
Files:
-
Modify:
src/routes/wishlist/+page.svelte -
Step 1: Import ergänzen
In der Lucide-Import-Zeile (Zeile 3):
import { Utensils, Trash2, CookingPot, ShoppingCart } from 'lucide-svelte';
Dazu:
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
- Step 2: toggleCart-Funktion schreiben
Nach removeForAll:
async function toggleCart(entry: WishlistEntry) {
if (!requireOnline('Die Einkaufsliste')) return;
if (shoppingCartStore.isInCart(entry.recipe_id)) {
await shoppingCartStore.removeRecipe(entry.recipe_id);
} else {
await shoppingCartStore.addRecipe(entry.recipe_id);
}
}
Im onMount ergänzen:
void shoppingCartStore.refresh();
- Step 3: Markup umbauen — Domain raus, Action-Leiste NEBEN dem Link (nicht darin)
Wichtig: Buttons dürfen nicht innerhalb eines <a> sitzen (interaktive Descendants sind im HTML-Content-Model nicht erlaubt). Darum trennen wir <a class="body"> und <div class="actions-top"> als Geschwister und positionieren die Action-Leiste per CSS position: absolute oben rechts in der Card.
Im <li class="card">-Template (Zeilen 114-157 der aktuellen Datei), das aktuelle Layout ersetzen durch:
<li class="card">
<a class="body" href={`/recipes/${e.recipe_id}`}>
{#if resolveImage(e.image_path)}
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={32} /></div>
{/if}
<div class="text">
<div class="title">{e.title}</div>
<div class="meta">
{#if e.wanted_by_names}
<span class="wanted-by">{e.wanted_by_names}</span>
{/if}
{#if e.avg_stars !== null}
<span>· ★ {e.avg_stars.toFixed(1)}</span>
{/if}
</div>
</div>
</a>
<div class="actions-top">
<button
class="like"
class:active={e.on_my_wishlist}
aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
onclick={() => toggleMine(e)}
>
<Utensils size={18} strokeWidth={2} />
{#if e.wanted_by_count > 0}
<span class="count">{e.wanted_by_count}</span>
{/if}
</button>
<button
class="cart"
class:active={shoppingCartStore.isInCart(e.recipe_id)}
aria-label={shoppingCartStore.isInCart(e.recipe_id)
? 'Aus Einkaufswagen entfernen'
: 'In den Einkaufswagen'}
onclick={() => toggleCart(e)}
>
<ShoppingCart size={18} strokeWidth={2} />
</button>
<button
class="del"
aria-label="Für alle entfernen"
onclick={() => removeForAll(e)}
>
<Trash2 size={18} strokeWidth={2} />
</button>
</div>
</li>
Wichtige Anpassungen:
-
<a class="body">umschließt NUR Bild + Text, keine Buttons. -
<div class="actions-top">ist Geschwister von<a class="body">und wird per CSS oben rechts in der.cardpositioniert (Card istposition: relative). -
<span class="src">mit Domain ist raus. -
Step 4: Styles anpassen
Im <style>-Block: .actions entfernen, stattdessen hinzufügen:
.card {
position: relative; /* neu: damit .actions-top absolut positioniert werden kann */
}
.actions-top {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.4rem;
z-index: 1;
}
.like,
.cart,
.del {
min-width: 44px;
min-height: 44px;
border-radius: 10px;
border: 1px solid #e4eae7;
background: white;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
font-size: 1rem;
color: #444;
}
.like.active {
color: #2b6a3d;
background: #eaf4ed;
border-color: #b7d6c2;
}
.cart.active {
color: #2b6a3d;
background: #eaf4ed;
border-color: #b7d6c2;
}
.del:hover {
color: #c53030;
border-color: #f1b4b4;
background: #fdf3f3;
}
.count {
font-size: 0.85rem;
font-weight: 600;
}
.text {
flex: 1;
/* padding-right etwa = Breite von 3 Buttons (44px) * 3 + 2 * gap (0.4rem) + right (0.5rem) ≈ 170px */
padding: 0.7rem 170px 0.7rem 0.75rem;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
Die bisherige .actions-Regel und die bisherigen .like/.del-Styles darunter können raus, sind durch Obiges ersetzt. Die bestehende .card-Regel muss um position: relative ergänzt werden (falls die andere Regel schon existiert, einfach den Property dort einsetzen).
- Step 5: Manueller Test + svelte-check
Run: npm run dev, http://localhost:5173/wishlist. Verify auf Handy-Viewport (Chrome DevTools mobile 375px): Titel hat Platz, 3 Buttons oben rechts nebeneinander, keine Domain mehr.
Run: npm run check
Expected: 0/0
- Step 6: Commit
git add src/routes/wishlist/+page.svelte
git commit -m "refactor(wishlist): horizontale Actions + Einkaufswagen-Button"
git push
Phase 9 — Einkaufslisten-Seite
Task 20: Empty-State + Seiten-Grundgerüst
Files:
-
Create:
src/routes/shopping-list/+page.svelte -
Step 1: Grundgerüst + Empty State
<script lang="ts">
import { onMount } from 'svelte';
import { ShoppingCart } from 'lucide-svelte';
import type { ShoppingListSnapshot } from '$lib/server/shopping/repository';
let snapshot = $state<ShoppingListSnapshot>({ recipes: [], rows: [], uncheckedCount: 0 });
let loading = $state(true);
async function load() {
loading = true;
try {
const res = await fetch('/api/shopping-list');
snapshot = await res.json();
} finally {
loading = false;
}
}
onMount(load);
</script>
<header class="head">
<h1>Einkaufsliste</h1>
{#if snapshot.recipes.length > 0}
<p class="sub">
{snapshot.uncheckedCount} noch zu besorgen · {snapshot.recipes.length} Rezept{snapshot.recipes.length === 1 ? '' : 'e'} im Wagen
</p>
{/if}
</header>
{#if loading}
<p class="muted">Lädt …</p>
{:else if snapshot.recipes.length === 0}
<section class="empty">
<div class="big"><ShoppingCart size={48} strokeWidth={1.5} /></div>
<p>Einkaufswagen ist leer.</p>
<p class="hint">Lege Rezepte auf der Wunschliste in den Wagen, um sie hier zu sehen.</p>
</section>
{/if}
<style>
.head { padding: 1.25rem 0 0.5rem; }
.head h1 { margin: 0; font-size: 1.6rem; color: #2b6a3d; }
.sub { margin: 0.2rem 0 0; color: #666; }
.muted { color: #888; text-align: center; padding: 2rem 0; }
.empty { text-align: center; padding: 3rem 1rem; }
.big { color: #8fb097; display: inline-flex; margin: 0 0 0.5rem; }
.hint { color: #888; font-size: 0.9rem; }
</style>
Hinweis: In diesem Zwischenstand rendert die Seite im „populated"-Fall nur den Header mit Counts — der {:else}-Zweig mit Chips/Rows/Footer kommt in Tasks 21-23. Lauffähig, kein Placeholder.
- Step 2: svelte-check + manueller Test
Run: npm run check && npm run dev
Besuche http://localhost:5173/shopping-list — Empty State sollte korrekt erscheinen.
- Step 3: Commit
git add src/routes/shopping-list/+page.svelte
git commit -m "feat(shopping): Einkaufslisten-Seite mit Empty-State"
git push
Task 21: Zutaten-Rows mit Checkbox
Files:
-
Create:
src/lib/components/ShoppingListRow.svelte -
Modify:
src/routes/shopping-list/+page.svelte -
Step 1: Row-Komponente
<script lang="ts">
import type { ShoppingListRow } from '$lib/server/shopping/repository';
import { formatQuantity } from '$lib/quantity-format';
let { row, onToggle }: {
row: ShoppingListRow;
onToggle: (row: ShoppingListRow, next: boolean) => void;
} = $props();
const qtyStr = $derived(formatQuantity(row.total_quantity));
const hasUnit = $derived(!!row.display_unit && row.display_unit.trim().length > 0);
</script>
<label class="row" class:checked={row.checked}>
<input
type="checkbox"
checked={row.checked === 1}
onchange={(e) => onToggle(row, (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="text">
<span class="name">
{#if qtyStr}
<span class="qty">{qtyStr}{hasUnit ? ` ${row.display_unit}` : ''}</span>
{/if}
{row.display_name}
</span>
<span class="src">aus {row.from_recipes}</span>
</span>
</label>
<style>
.row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem;
border: 1px solid #e4eae7;
border-radius: 10px;
background: white;
cursor: pointer;
min-height: 60px;
}
.row input {
width: 24px;
height: 24px;
margin-top: 0.1rem;
flex-shrink: 0;
accent-color: #2b6a3d;
}
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.name { font-size: 1rem; }
.qty { font-weight: 600; margin-right: 0.3rem; }
.src { color: #888; font-size: 0.82rem; }
.row.checked { background: #f6f8f7; }
.row.checked .name,
.row.checked .qty { text-decoration: line-through; color: #888; }
</style>
- Step 2: Page-Einbindung
In src/routes/shopping-list/+page.svelte:
Import ergänzen:
import ShoppingListRow from '$lib/components/ShoppingListRow.svelte';
import type { ShoppingListRow as Row } from '$lib/server/shopping/repository';
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
Funktion hinzufügen:
async function onToggleRow(row: Row, next: boolean) {
const method = next ? 'POST' : 'DELETE';
await fetch('/api/shopping-list/check', {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name_key: row.name_key, unit_key: row.unit_key })
});
await load();
void shoppingCartStore.refresh();
}
Die {#if}-Kette aus Task 20 um einen {:else}-Zweig mit der Liste erweitern (zwischen {/if} und <style> einfügen, bzw. den bisherigen {/if} durch folgendes ersetzen):
{:else}
<ul class="list">
{#each snapshot.rows as row (row.name_key + '|' + row.unit_key)}
<li>
<ShoppingListRow {row} onToggle={onToggleRow} />
</li>
{/each}
</ul>
{/if}
Styles ergänzen:
.list {
list-style: none;
padding: 0;
margin: 0.75rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
- Step 3: svelte-check + manueller Test
Run: npm run check
Test im Browser: Lege ein Rezept via Wunschliste in den Cart → navigiere zu /shopping-list → Zutaten erscheinen → Abhaken → Reload → Status persistiert → Badge-Count im Header sinkt.
- Step 4: Commit
git add src/lib/components/ShoppingListRow.svelte src/routes/shopping-list/+page.svelte
git commit -m "feat(shopping): Zutaten-Rows mit Abhaken"
git push
Task 22: Rezept-Chips mit Portions-Stepper
Files:
-
Create:
src/lib/components/ShoppingCartChip.svelte -
Modify:
src/routes/shopping-list/+page.svelte -
Step 1: Chip-Komponente
<script lang="ts">
import { X, Minus, Plus } from 'lucide-svelte';
import type { ShoppingCartRecipe } from '$lib/server/shopping/repository';
let { recipe, onServingsChange, onRemove }: {
recipe: ShoppingCartRecipe;
onServingsChange: (id: number, servings: number) => void;
onRemove: (id: number) => void;
} = $props();
function dec() {
if (recipe.servings > 1) onServingsChange(recipe.recipe_id, recipe.servings - 1);
}
function inc() {
if (recipe.servings < 50) onServingsChange(recipe.recipe_id, recipe.servings + 1);
}
</script>
<div class="chip">
<a class="title" href={`/recipes/${recipe.recipe_id}`}>{recipe.title}</a>
<div class="controls">
<button aria-label="Portion weniger" onclick={dec} disabled={recipe.servings <= 1}>
<Minus size={16} />
</button>
<span class="val" aria-label="Portionen">{recipe.servings}p</span>
<button aria-label="Portion mehr" onclick={inc} disabled={recipe.servings >= 50}>
<Plus size={16} />
</button>
<button aria-label="Rezept aus Einkaufsliste entfernen" class="rm" onclick={() => onRemove(recipe.recipe_id)}>
<X size={16} />
</button>
</div>
</div>
<style>
.chip {
flex: 0 0 auto;
padding: 0.5rem 0.75rem;
background: white;
border: 1px solid #cfd9d1;
border-radius: 14px;
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 140px;
}
.title {
color: #2b6a3d;
font-weight: 600;
font-size: 0.92rem;
text-decoration: none;
line-height: 1.2;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls { display: flex; gap: 0.25rem; align-items: center; }
.controls button {
min-width: 32px;
min-height: 32px;
border-radius: 8px;
border: 1px solid #e4eae7;
background: white;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: #444;
}
.controls button:disabled { opacity: 0.4; cursor: not-allowed; }
.controls button.rm { margin-left: auto; }
.controls button.rm:hover { color: #c53030; border-color: #f1b4b4; background: #fdf3f3; }
.val { min-width: 32px; text-align: center; font-weight: 600; color: #444; }
</style>
- Step 2: Page-Einbindung
In src/routes/shopping-list/+page.svelte:
Import + Handler:
import ShoppingCartChip from '$lib/components/ShoppingCartChip.svelte';
async function onServingsChange(recipeId: number, servings: number) {
await fetch(`/api/shopping-list/recipe/${recipeId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servings })
});
await load();
void shoppingCartStore.refresh();
}
async function onRemoveRecipe(recipeId: number) {
await fetch(`/api/shopping-list/recipe/${recipeId}`, { method: 'DELETE' });
await load();
void shoppingCartStore.refresh();
}
Markup: direkt VOR der <ul class="list"> die Chip-Leiste einfügen:
<div class="chips">
{#each snapshot.recipes as r (r.recipe_id)}
<ShoppingCartChip recipe={r} {onServingsChange} onRemove={onRemoveRecipe} />
{/each}
</div>
Styles:
.chips {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 0.5rem 0;
margin: 0.5rem 0;
-webkit-overflow-scrolling: touch;
}
- Step 3: svelte-check + manueller Test
Test: Chips erscheinen, Plus/Minus ändert Portionen → Zutatenmengen reagieren live, X entfernt das Rezept.
- Step 4: Commit
git add src/lib/components/ShoppingCartChip.svelte src/routes/shopping-list/+page.svelte
git commit -m "feat(shopping): Rezept-Chips mit Portions-Stepper"
git push
Task 23: Footer-Actions (Erledigte entfernen, Liste leeren)
Files:
-
Modify:
src/routes/shopping-list/+page.svelte -
Step 1: Imports + Handler
Import ergänzen:
import { confirmAction } from '$lib/client/confirm.svelte';
Handler:
const hasChecked = $derived(snapshot.rows.some((r) => r.checked === 1));
async function clearChecked() {
await fetch('/api/shopping-list/checked', { method: 'DELETE' });
await load();
void shoppingCartStore.refresh();
}
async function clearAll() {
const ok = await confirmAction({
title: 'Einkaufsliste leeren?',
message: 'Alle Rezepte und abgehakten Zutaten werden entfernt. Das lässt sich nicht rückgängig machen.',
confirmLabel: 'Leeren',
destructive: true
});
if (!ok) return;
await fetch('/api/shopping-list', { method: 'DELETE' });
await load();
void shoppingCartStore.refresh();
}
- Step 2: Markup — Sticky Footer, nur rendern wenn recipes > 0
Nach der <ul class="list"> (noch innerhalb des {:else}-Blocks):
<div class="footer">
{#if hasChecked}
<button class="btn secondary" onclick={clearChecked}>Erledigte entfernen</button>
{/if}
<button class="btn destructive" onclick={clearAll}>Liste leeren</button>
</div>
Styles:
.footer {
position: sticky;
bottom: 0;
background: #f4f8f5;
padding: 0.75rem 0;
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
border-top: 1px solid #e4eae7;
}
.btn {
padding: 0.6rem 1rem;
border-radius: 10px;
border: 1px solid #cfd9d1;
background: white;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
min-height: 44px;
}
.btn.secondary { color: #2b6a3d; border-color: #b7d6c2; }
.btn.destructive { color: #c53030; border-color: #f1b4b4; }
.btn.destructive:hover { background: #fdf3f3; }
- Step 3: svelte-check + manueller Test
Tests:
-
„Erledigte entfernen" erscheint nur wenn mindestens eine Zeile abgehakt ist
-
Klick räumt vollständig abgehakte Rezepte raus
-
„Liste leeren" zeigt Confirm-Dialog; nach OK leer + Redirect zum Empty-State
-
Badge im Header verschwindet nach „Liste leeren"
-
Step 4: Commit
git add src/routes/shopping-list/+page.svelte
git commit -m "feat(shopping): Footer-Actions (Erledigte entfernen, Liste leeren)"
git push
Phase 10 — E2E-Tests (lauffähig nach Deploy)
Task 24: E2E-Suite schreiben
Files:
- Create:
tests/e2e/remote/shopping.spec.ts
Wichtig: Diese Tests laufen gegen kochwas-dev.siegeln.net. Vor Ausführung muss der Feature-Branch via CI auf Dev-Env deployed sein. Tests schreiben wir trotzdem jetzt, damit sie nach Deploy gleich lauffähig sind.
Fixture-Stil (bestätigt durch Sichtung von tests/e2e/remote/wishlist.spec.ts + fixtures/api-cleanup.ts):
-
Kein neues Anlegen von Test-Rezepten. Die Tests nutzen bereits auf dem Dev-System vorhandene Rezepte.
-
Profil-Setup via
setActiveProfile(page, HENDRIK_ID)ausfixtures/profile. -
Cleanup via
afterEach-Hook, der den API-EndpointDELETE /api/shopping-listaufruft. -
test.skip(condition, ...)wird genutzt, wenn Voraussetzungen fehlen (z. B. Dev-System hat keine Wunschliste-Einträge). -
Step 1: Cleanup-Helper ergänzen
In tests/e2e/remote/fixtures/api-cleanup.ts am Ende hinzufügen:
/**
* Leert den haushaltsweiten Einkaufswagen. Idempotent.
*/
export async function clearShoppingCart(api: APIRequestContext): Promise<void> {
await api.delete('/api/shopping-list');
}
- Step 2: E2E-Test-Datei schreiben
import { test, expect } from '@playwright/test';
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
import { clearShoppingCart } from './fixtures/api-cleanup';
test.describe('Einkaufsliste E2E', () => {
test.afterEach(async ({ request }) => {
await clearShoppingCart(request);
});
test('Cart-Button auf der Wunschliste erzeugt Header-Badge', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
// Voraussetzung: Dev-System hat mindestens einen Wunschlisten-Eintrag
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer auf Dev — Test uebersprungen');
await page.goto('/wishlist');
await page.getByLabel('In den Einkaufswagen').first().click();
await expect(page.getByLabel(/Einkaufsliste \(\d+\)/)).toBeVisible();
});
test('Shopping-List-Seite zeigt Rezept-Chip + Zutaten', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
const recipeId = wlBody.entries[0].recipe_id;
await request.post('/api/shopping-list/recipe', { data: { recipe_id: recipeId } });
await page.goto('/shopping-list');
await expect(page.getByRole('heading', { level: 1, name: 'Einkaufsliste' })).toBeVisible();
// Chip fuers Rezept sichtbar
await expect(page.getByLabel('Portion weniger').first()).toBeVisible();
// Mindestens eine Zutatenzeile
await expect(page.locator('.row').first()).toBeVisible();
});
test('Portions-Stepper veraendert Mengen live', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
await request.post('/api/shopping-list/recipe', {
data: { recipe_id: wlBody.entries[0].recipe_id, servings: 4 }
});
await page.goto('/shopping-list');
// Menge der ersten Zeile "vorher" lesen
const qtyBefore = await page.locator('.qty').first().textContent();
// Portion +1
await page.getByLabel('Portion mehr').first().click();
// Nach Fetch+Rerender muss die Menge sich aendern (ungleich dem Vorher-Wert)
await expect
.poll(async () => (await page.locator('.qty').first().textContent())?.trim())
.not.toBe(qtyBefore?.trim());
});
test('Abhaken: Zeile durchgestrichen, Badge-Count sinkt, persistiert nach Reload', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
await request.post('/api/shopping-list/recipe', {
data: { recipe_id: wlBody.entries[0].recipe_id }
});
await page.goto('/shopping-list');
const countBadge = page.getByLabel(/Einkaufsliste \(\d+\)/);
const badgeTextBefore = await countBadge.textContent();
const numBefore = Number((badgeTextBefore ?? '').replace(/\D+/g, '')) || 0;
const firstRow = page.locator('label.row').first();
await firstRow.locator('input[type=checkbox]').check();
await expect(firstRow).toHaveClass(/checked/);
// Badge muss sinken (nach Store-Refresh)
await expect
.poll(async () => {
const t = (await countBadge.textContent()) ?? '';
return Number(t.replace(/\D+/g, '')) || 0;
})
.toBeLessThan(numBefore);
// Reload persistiert
await page.reload();
await expect(page.locator('label.row.checked').first()).toBeVisible();
});
test('Liste leeren: Confirm + Empty-State + Badge weg', async ({ page, request }) => {
await setActiveProfile(page, HENDRIK_ID);
const wlRes = await request.get('/api/wishlist?sort=popular');
const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
await request.post('/api/shopping-list/recipe', {
data: { recipe_id: wlBody.entries[0].recipe_id }
});
await page.goto('/shopping-list');
await page.getByRole('button', { name: 'Liste leeren' }).click();
// Confirm-Dialog (ConfirmAction nutzt einen App-eigenen Dialog, kein native)
await page.getByRole('button', { name: 'Leeren' }).click();
await expect(page.getByText('Einkaufswagen ist leer.')).toBeVisible();
await expect(page.getByLabel(/Einkaufsliste \(\d+\)/)).toHaveCount(0);
});
});
Nicht direkt E2E-getestet (wegen Abhängigkeit von unbekanntem Dev-Rezept-Stand):
-
„Erledigte entfernen" mit Vollstaendig-vs-Teilweise-Logik → über Integration-Tests abgedeckt
-
Aggregation über zwei Rezepte mit gleichem Zutaten-Key → über Integration-Tests abgedeckt (zuverlässige Fixtures)
-
Step 3: svelte-check + TypeScript-Validierung
Run: npm run check
Expected: 0/0
- Step 4: Commit
git add tests/e2e/remote/shopping.spec.ts tests/e2e/remote/fixtures/api-cleanup.ts
git commit -m "test(shopping): E2E-Spec + clearShoppingCart-Fixture"
git push
Nach-Deploy-Checkliste
Nach dem Merge in main und erfolgreichem CI-Build + Deploy auf kochwas-dev.siegeln.net:
- E2E-Suite ausführen:
npx playwright test tests/e2e/remote/shopping.spec.ts - Manuell auf dem Handy testen (PWA-Install, Offline-Verhalten — Liste sollte bei Offline leer / Fehlermeldung zeigen, nicht crashen)
- Prod-Deploy nach grünem Dev-Run
Out of Scope (nicht in diesem Plan)
- Manuelle Einträge (Klopapier etc.)
- Offline-Queue für Mutating-Calls
- Supermarkt-Abteilungs-Sortierung
- Fuzzy-Matching von Zutaten-Synonymen
- Auto-Kopplung an
cooking_log/ Wunschliste-Remove beim Abhaken