From 956357d5ca3beabed83df6567504df4d5e8a8394 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:25:46 +0200 Subject: [PATCH] docs(spec): Einkaufsliste-Design Neue Spec fuer das Einkaufslisten-Feature: - Globale (haushaltsweite) Einkaufsliste, aus Rezepten der Wunschliste gefuellt - Portionen zentral auf der Listen-Seite skalierbar - Flache Aggregation via (LOWER(TRIM(name)), LOWER(TRIM(unit))) - Abhaken persistiert, Cleanup manuell - Header-Badge zaehlt nicht-abgehakte Zeilen - Relayout der Wunschlisten-Karte: Action-Icons horizontal oben, Quell-Domain raus - Kein Fuzzy-Matching, keine manuellen Eintraege (YAGNI fuer v1) E2E-Tests erst nach Deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-21-shopping-list-design.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-21-shopping-list-design.md diff --git a/docs/superpowers/specs/2026-04-21-shopping-list-design.md b/docs/superpowers/specs/2026-04-21-shopping-list-design.md new file mode 100644 index 0000000..2626bf9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-shopping-list-design.md @@ -0,0 +1,295 @@ +# Einkaufsliste — Design-Spec + +**Datum**: 2026-04-21 +**Status**: Spec, vor Implementierung + +## Ziel + +Aus Rezepten auf der Wunschliste eine flache, aggregierte Einkaufsliste erzeugen. Die Liste ist haushaltsweit geteilt, mobil-first, im Supermarkt abhakbar. Portionen sind pro Rezept anpassbar. Identische Zutaten (gleicher Name + gleiche Einheit) werden über mehrere Rezepte hinweg summiert. + +## Entscheidungen (aus Brainstorming) + +| Thema | Entscheidung | +|---|---| +| Sichtbarkeit | Global, eine Liste für alle Profile | +| Portionen | Default `servings_default` beim Hinzufügen; zentral auf der Einkaufslisten-Seite anpassbar | +| Aggregation | Flache Liste, exaktes Matching auf `(LOWER(TRIM(name)), LOWER(TRIM(unit)))`. Keine Fuzzy-Matches — lieber zwei Zeilen als falsche Summen. Rezept-Herkunft pro Zeile sichtbar. | +| Abhaken | Checkbox, durchgestrichen, sortiert ans Ende. Manuelles Cleanup via „Erledigte entfernen" / „Liste leeren" | +| Kopplung | Komplett entkoppelt von Wunschliste und `cooking_log`. Abhaken beeinflusst nur die Einkaufsliste. | +| Header-Badge | Zählt **nicht-abgehakte** aggregierte Zutaten-Zeilen. Versteckt sich bei Count = 0. | +| Manuelle Einträge | Out of scope. Nur rezeptbasiert. | + +## Datenmodell + +Migration `013_shopping_list.sql`: + +```sql +CREATE TABLE shopping_cart_recipe ( + recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE, + servings INTEGER NOT NULL, + 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) +); +``` + +**Derivation-Prinzip**: Die aggregierte Liste wird **nicht materialisiert**. Sie wird bei jedem Lesen aus `shopping_cart_recipe JOIN recipe JOIN ingredient` plus Skalierungs-Faktor berechnet. Vorteil: Rezept-Edits wirken live auf die Liste. + +**Abhaken pro aggregierter Zeile**: `(name_key, unit_key)` — nicht pro Rezept-Zutat. Wenn zwei Rezepte beide „Mehl, g" haben, gibt es eine Zeile „400 g Mehl", und ein Haken reicht. Wird eines der Rezepte entfernt, bleibt „200 g Mehl" mit Haken sichtbar. + +**Orphan-Checks** (aggregierter Schlüssel ist nicht mehr durch ein Rezept im Cart abgedeckt): Werden nicht aktiv gelöscht, tauchen aber in der Ausgabe von `listShoppingList` nicht auf (der Join erzeugt keine Zeile). Späteres Cleanup optional via `clearCart` / `clearCheckedItems`. + +### Aggregations-SQL (Kern) + +```sql +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 / r.servings_default) 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; +``` + +**Edge Cases**: +- `i.quantity IS NULL` → `total_quantity` bleibt NULL, UI rendert ohne Mengenangabe. +- `r.servings_default IS NULL` → Division through-by-NULL → `total_quantity` NULL; defensiver: `COALESCE(r.servings_default, cr.servings)` (Faktor = 1, wenn kein Default bekannt). +- `i.unit IS NULL` → `unit_key = ''`, Anzeige ohne Einheit. +- Rezept hat keine Zutaten (sehr selten) → kein Beitrag zur Liste, Rezept-Chip erscheint trotzdem (Signal: „ups, keine Zutaten"). + +## Server-Module + +### `src/lib/server/shopping/repository.ts` + +Neue Typen: + +```ts +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; // comma-separated recipe titles + checked: 0 | 1; +}; + +export type ShoppingListSnapshot = { + recipes: ShoppingCartRecipe[]; + rows: ShoppingListRow[]; + uncheckedCount: number; +}; +``` + +Funktionen: + +- `addRecipeToCart(db, recipeId, profileId, servings?)` — `INSERT … ON CONFLICT(recipe_id) DO UPDATE SET servings = excluded.servings`. Wenn `servings` fehlt, nimmt `COALESCE(recipe.servings_default, 4)`. +- `removeRecipeFromCart(db, recipeId)` +- `setCartServings(db, recipeId, servings)` — App-seitig validiert: `1 ≤ servings ≤ 50`. SQL-Level `CHECK (servings > 0)` zusätzlich als Sicherheitsnetz. +- `listShoppingList(db) → ShoppingListSnapshot` — liefert Cart-Rezepte, aggregierte Zeilen und `uncheckedCount` in einer Transaktion. +- `toggleCheck(db, nameKey, unitKey, checked: boolean)` — Insert bzw. Delete in `shopping_cart_check`. +- `clearCheckedItems(db)` — transaktional: + 1. Aggregation laufen lassen und `recipe_id`s finden, deren sämtliche aggregierten Zeilen abgehakt sind (ein Rezept zählt als „erledigt", wenn all seine `(name_key, unit_key)`-Beiträge in `shopping_cart_check` stehen) + 2. Diese Rezepte via `DELETE FROM shopping_cart_recipe WHERE recipe_id IN (…)` entfernen + 3. Check-Einträge, die jetzt keinen Bezug mehr haben, mit `DELETE FROM shopping_cart_check WHERE (name_key, unit_key) NOT IN ()` aufräumen +- `clearCart(db)` — `DELETE FROM shopping_cart_recipe; DELETE FROM shopping_cart_check;` + +### Routen + +| Methode + Pfad | Body/Params | Zweck | +|---|---|---| +| `GET /api/shopping-list` | — | Snapshot holen | +| `POST /api/shopping-list/recipe` | `{ recipe_id, servings?, profile_id? }` | Rezept in Cart; idempotent | +| `PATCH /api/shopping-list/recipe/:recipe_id` | `{ servings }` | Portionen ändern | +| `DELETE /api/shopping-list/recipe/:recipe_id` | — | Rezept raus | +| `POST /api/shopping-list/check` | `{ name_key, unit_key }` | Abhaken | +| `DELETE /api/shopping-list/check` | `{ name_key, unit_key }` | Haken weg | +| `DELETE /api/shopping-list/checked` | — | Erledigte entfernen | +| `DELETE /api/shopping-list` | — | Liste leeren | + +Error-Handling: 404 wenn `recipe_id` nicht im Cart (nur bei DELETE/PATCH auf spezifischem Rezept), 400 bei Validation-Fehlern (servings ≤ 0, fehlende Felder), 500 mit JSON-Body `{ message }` bei DB-Fehlern. + +## Client-Store + +`src/lib/client/shopping-cart.svelte.ts` — analog zu `wishlist.svelte.ts`: + +```ts +class ShoppingCartStore { + uncheckedCount = $state(0); + recipeIds = $state>(new Set()); // für „ist dieses Rezept im Cart?" + loaded = $state(false); + + async refresh(): Promise; + async addRecipe(recipeId: number): Promise; + async removeRecipe(recipeId: number): Promise; + isInCart(recipeId: number): boolean; +} +``` + +- `refresh()` ruft `GET /api/shopping-list` auf und extrahiert `recipeIds` + `uncheckedCount` aus dem Snapshot. Ein separater Leichtgewichts-Count-Endpoint ist nicht nötig; der Snapshot ist klein. +- Store wird in `+layout.svelte` beim `onMount` initialisiert (wie `wishlistStore.refresh()`). +- Nach jedem Mutating-Call (add/remove/toggle/clear) wird `refresh()` vom aufrufenden Code getriggert. + +## UI + +### (a) Wunschlisten-Karte — Relayout + +Aktuell drücken zwei rechts-gestapelte Buttons den Titel-Text auf Handys zusammen. Neues Layout: + +``` +┌──────────┬─────────────────────────────┐ +│ │ [Utensils|3] [Cart] [Trash] │ Action-Leiste oben, horizontal +│ Bild │ Titel (fett, 2 Zeilen max) │ +│ 96px │ Hendrik, Verena, Leana │ wanted_by + ★ +│ │ ★ 4.5 │ +└──────────┴─────────────────────────────┘ +``` + +Konkret in `src/routes/wishlist/+page.svelte`: +- `.actions` wird horizontal, als erste Zeile über dem Titel rechts-bündig. +- `source_domain`-Span aus der `.meta`-Zeile entfernt (Platz). +- Neuer Cart-Button zwischen Utensils und Trash: + - Nicht im Cart: neutral (Icon grau), aria-label „In den Einkaufswagen" + - Im Cart: grün gefüllt, Häkchen-Badge unten rechts, aria-label „Aus Einkaufswagen entfernen" +- Alle drei Buttons ≥ 44 × 44 px (mobile Tap-Target). + +Vergleichbare Reorg in `src/routes/recipes/[id]/+page.svelte` nötig? — **Nein**. Der Cart-Button erscheint nur auf der Wunschliste. (Begründung: Rezept-Detail hat schon ein volles Action-Menü; das Hinzufügen zum Cart passiert bewusst aus der Wunschlisten-Perspektive.) + +### (b) Header-Badge + +`src/routes/+layout.svelte` — rechts neben dem bestehenden Kochtopf-Icon: + +- Icon `ShoppingCart` aus `lucide-svelte` +- Badge-Kreis oben rechts mit `shoppingCartStore.uncheckedCount` +- Nur sichtbar wenn `uncheckedCount > 0` +- Klick → `goto('/shopping-list')` +- Gleicher Visual-Style wie der CookingPot (Farb-Konsistenz grün) + +### (c) Seite `/shopping-list` + +Datei: `src/routes/shopping-list/+page.svelte` + +``` +┌──────────────────────────────────────┐ +│ Einkaufsliste │ Header +│ 12 noch zu besorgen · 3 Rezepte │ +├──────────────────────────────────────┤ +│ [Carbonara 4p- +] [Lasagne 6p- +] … │ Rezept-Chips, horizontal scrollbar +│ │ (Titel + Portions-Stepper + X) +├──────────────────────────────────────┤ +│ ☐ 400 g Mehl │ +│ aus Carbonara, Lasagne │ +│ ☐ 6 Stk Eier │ +│ aus Carbonara │ +│ … │ +│ ☑ 200 g Butter (durchgestrichen) │ Abgehakt, ans Ende +├──────────────────────────────────────┤ +│ [Erledigte entfernen] [Liste leeren] │ Sticky Footer +└──────────────────────────────────────┘ +``` + +**Komponenten** (neue Svelte-Dateien): +- `src/lib/components/ShoppingCartChip.svelte` — Rezept-Chip mit Stepper + Remove +- `src/lib/components/ShoppingListRow.svelte` — eine Zutatenzeile mit Checkbox + +**Portions-Stepper**: - und + Buttons, mittig die Zahl. Min 1, Max 50 (sanity). Klick sendet PATCH, triggert Store-Refresh → Liste rerendert. + +**Zutaten-Reihenfolge**: Erst nicht-abgehakt, dann abgehakt; innerhalb jeder Gruppe alphabetisch (`display_name COLLATE NOCASE`). Abgehakt = durchgestrichen + grauer Text. + +**Mengen-Formatierung** (`src/lib/quantity-format.ts`, neu): +- `formatQuantity(q: number | null): string` +- `null` → `''` +- Ganz-nahe-Ganzzahl (Epsilon 0.01) → Integer +- Sonst auf max. 2 Nachkommastellen, trailing Nullen weg +- Beispiele: `400 → "400"`, `0.5 → "0.5"`, `0.333 → "0.33"`, `null → ""` + +**Aktionen im Footer**: +- „Erledigte entfernen" — sichtbar wenn ≥ 1 Check, kein Confirm (reversibel genug) +- „Liste leeren" — Confirm via `confirmAction`: „Komplette Einkaufsliste löschen? Das macht nicht rückgängig." + +**Empty State**: Icon `ShoppingCart` (große Version), „Einkaufswagen ist leer", Hint „Lege Rezepte auf der Wunschliste in den Wagen, um sie hier zu sehen." + +**Offline-Verhalten**: Wie die Wunschliste — alle Mutating-Calls via `requireOnline()`. Service-Worker cached nichts von `/api/shopping-list/*` (network-only analog zu Wishlist). Die PWA-Seite selbst wird vom SW-Shell-Cache serviert, aber ohne Daten. Offline-Robustheit (local queue + sync) ist **out of scope** für v1. + +## Testing + +### Unit/Integration-Tests (Vitest, in-memory DB) + +- `tests/integration/shopping-repository.test.ts`: + - `addRecipeToCart` idempotent, `ON CONFLICT` überschreibt `servings` + - Aggregation: gleiche `(name_key, unit_key)` summiert; unterschiedliche unit_keys bleiben getrennt + - Portions-Skalierung: `servings_default=4`, `servings=2` → alle Mengen halbiert + - Nulls: `quantity IS NULL` → `total_quantity IS NULL`; `unit IS NULL` → `unit_key=''` + - `toggleCheck` persistiert über `listShoppingList`-Aufrufe + - Abgehakt-Status überlebt Entfernen eines Rezepts, solange Schlüssel von einem anderen kommt + - `clearCheckedItems`: entfernt nur vollständig abgehakte Rezepte + räumt Orphan-Checks + - `countUncheckedItems` nach diversen Ops korrekt + - `clearCart` cleant beide Tabellen +- `tests/unit/shopping-cart-store.test.ts`: + - Mock-Fetch, testet refresh-Trigger nach add/remove + - `isInCart(id)` reflektiert aktuellen Zustand + - `uncheckedCount` reactive nach refresh +- `tests/unit/quantity-format.test.ts`: + - `formatQuantity(400) === "400"` + - `formatQuantity(0.5) === "0.5"` + - `formatQuantity(0.333333) === "0.33"` + - `formatQuantity(400.001) === "400"` (Epsilon) + - `formatQuantity(null) === ""` + +### E2E-Tests (Playwright, `tests/e2e/remote/shopping.spec.ts`) + +**Wichtig**: E2E-Tests laufen gegen `kochwas-dev.siegeln.net` und erfordern einen erfolgreichen Deploy des Features. Werden nach dem Feature-Merge manuell ausgelöst, nicht im Rahmen der Implementierungs-Phase. + +Abgedeckt: +- Rezept auf Wunschliste → Cart-Button klicken → Header-Badge erscheint +- Navigation zu `/shopping-list`, Portions-Stepper hoch/runter → Zutatenmengen reagieren +- Zutat abhaken → Badge-Count sinkt, Zeile durchgestrichen, Reload persistiert +- „Erledigte entfernen" → vollständig abgehakte Rezepte weg, teilweise abgehakte bleiben +- „Liste leeren" → Empty-State, Badge verschwindet +- Zwei Rezepte mit gleicher Zutat (Fixture-Setup) → aggregierte Zeile mit Summe +- Cleanup-Fixture entfernt Cart + Checks nach jedem Test + +**Nicht getestet**: exakte CSS-Styles, Animationen — visuelle Kontrolle beim Deploy. + +## Implementierungs-Reihenfolge (Hinweis für Plan) + +1. Migration 013 + Repository + Unit-Tests +2. API-Routen + Integrationstests +3. Client-Store +4. Header-Badge-Icon +5. Wunschlisten-Karte Relayout + Cart-Button +6. Seite `/shopping-list` (Chips → Rows → Footer → Empty State) +7. Quantity-Formatter + Tests +8. Service-Worker network-only für `/api/shopping-list/*` +9. Deploy, dann E2E-Tests nachschieben + +## Out of Scope (für v1) + +- Manuelle Einträge („Klopapier") +- Supermarkt-Abteilungs-Sortierung +- Offline-Queue (add/check während offline, sync später) +- Synonym/Fuzzy-Matching von Zutaten-Namen (der User harmonisiert langfristig händisch) +- Auto-Kopplung zu `cooking_log` / Wunschliste-Remove +- Teilen per Link / Export