Files
kochwas/docs/superpowers/plans/2026-04-21-shopping-list.md

2294 lines
66 KiB
Markdown
Raw Normal View History

# 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> = {}): 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<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**
```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<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**
```ts
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**
```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 `<header>`-Block, direkt **nach** dem `</a>`-Ende des `wishlist-link` (nach Zeile 270), vor `<div class="menu-wrap">`, einfügen:
```svelte
{#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**
```bash
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):
```ts
import { Utensils, Trash2, CookingPot, ShoppingCart } from 'lucide-svelte';
```
Dazu:
```ts
import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
```
- [ ] **Step 2: toggleCart-Funktion schreiben**
Nach `removeForAll`:
```ts
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:
```ts
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:
```svelte
<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 `.card` positioniert (Card ist `position: relative`).
- `<span class="src">` mit Domain ist raus.
- [ ] **Step 4: Styles anpassen**
Im `<style>`-Block: `.actions` entfernen, stattdessen hinzufügen:
```css
.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**
```bash
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**
```svelte
<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**
```bash
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**
```svelte
<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:
```ts
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:
```ts
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):
```svelte
{: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:
```css
.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**
```bash
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**
```svelte
<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:
```ts
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:
```svelte
<div class="chips">
{#each snapshot.recipes as r (r.recipe_id)}
<ShoppingCartChip recipe={r} {onServingsChange} onRemove={onRemoveRecipe} />
{/each}
</div>
```
Styles:
```css
.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**
```bash
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:
```ts
import { confirmAction } from '$lib/client/confirm.svelte';
```
Handler:
```ts
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):
```svelte
<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:
```css
.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**
```bash
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)` aus `fixtures/profile`.
- Cleanup via `afterEach`-Hook, der den API-Endpoint `DELETE /api/shopping-list` aufruft.
- `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:
```ts
/**
* 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**
```ts
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**
```bash
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:
```bash
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