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) <noreply@anthropic.com>
14 KiB
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:
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)
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_quantitybleibt NULL, UI rendert ohne Mengenangabe.r.servings_default IS NULL→ Division through-by-NULL →total_quantityNULL; 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:
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. Wennservingsfehlt, nimmtCOALESCE(recipe.servings_default, 4).removeRecipeFromCart(db, recipeId)setCartServings(db, recipeId, servings)— App-seitig validiert:1 ≤ servings ≤ 50. SQL-LevelCHECK (servings > 0)zusätzlich als Sicherheitsnetz.listShoppingList(db) → ShoppingListSnapshot— liefert Cart-Rezepte, aggregierte Zeilen unduncheckedCountin einer Transaktion.toggleCheck(db, nameKey, unitKey, checked: boolean)— Insert bzw. Delete inshopping_cart_check.clearCheckedItems(db)— transaktional:- Aggregation laufen lassen und
recipe_ids finden, deren sämtliche aggregierten Zeilen abgehakt sind (ein Rezept zählt als „erledigt", wenn all seine(name_key, unit_key)-Beiträge inshopping_cart_checkstehen) - Diese Rezepte via
DELETE FROM shopping_cart_recipe WHERE recipe_id IN (…)entfernen - Check-Einträge, die jetzt keinen Bezug mehr haben, mit
DELETE FROM shopping_cart_check WHERE (name_key, unit_key) NOT IN (<aktive Keys nach Step 2>)aufräumen
- Aggregation laufen lassen und
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:
class ShoppingCartStore {
uncheckedCount = $state(0);
recipeIds = $state<Set<number>>(new Set()); // für „ist dieses Rezept im Cart?"
loaded = $state(false);
async refresh(): Promise<void>;
async addRecipe(recipeId: number): Promise<void>;
async removeRecipe(recipeId: number): Promise<void>;
isInCart(recipeId: number): boolean;
}
refresh()ruftGET /api/shopping-listauf und extrahiertrecipeIds+uncheckedCountaus dem Snapshot. Ein separater Leichtgewichts-Count-Endpoint ist nicht nötig; der Snapshot ist klein.- Store wird in
+layout.sveltebeimonMountinitialisiert (wiewishlistStore.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:
.actionswird 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
ShoppingCartauslucide-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 + Removesrc/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): stringnull→''- 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:addRecipeToCartidempotent,ON CONFLICTüberschreibtservings- 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='' toggleCheckpersistiert überlistShoppingList-Aufrufe- Abgehakt-Status überlebt Entfernen eines Rezepts, solange Schlüssel von einem anderen kommt
clearCheckedItems: entfernt nur vollständig abgehakte Rezepte + räumt Orphan-CheckscountUncheckedItemsnach diversen Ops korrektclearCartcleant beide Tabellen
tests/unit/shopping-cart-store.test.ts:- Mock-Fetch, testet refresh-Trigger nach add/remove
isInCart(id)reflektiert aktuellen ZustanduncheckedCountreactive 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)
- Migration 013 + Repository + Unit-Tests
- API-Routen + Integrationstests
- Client-Store
- Header-Badge-Icon
- Wunschlisten-Karte Relayout + Cart-Button
- Seite
/shopping-list(Chips → Rows → Footer → Empty State) - Quantity-Formatter + Tests
- Service-Worker network-only für
/api/shopping-list/* - 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