Files
kochwas/docs/superpowers/specs/2026-04-21-shopping-list-design.md
hsiegeln 956357d5ca 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) <noreply@anthropic.com>
2026-04-21 22:25:46 +02:00

14 KiB
Raw Permalink Blame History

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 NULLtotal_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 NULLunit_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. 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_ids 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 (<aktive Keys nach Step 2>) 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:

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() 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 NULLtotal_quantity IS NULL; unit IS NULLunit_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