2294 lines
66 KiB
Markdown
2294 lines
66 KiB
Markdown
|
|
# 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
|