296 lines
14 KiB
Markdown
296 lines
14 KiB
Markdown
|
|
# 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 (<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`:
|
|||
|
|
|
|||
|
|
```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 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
|