diff --git a/docs/superpowers/specs/2026-04-22-einkaufsliste-konsolidierung-design.md b/docs/superpowers/specs/2026-04-22-einkaufsliste-konsolidierung-design.md new file mode 100644 index 0000000..22b1c25 --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-einkaufsliste-konsolidierung-design.md @@ -0,0 +1,208 @@ +# Einkaufsliste: Mengen-Konsolidierung über Einheiten + +## Kontext + +Die Einkaufsliste (`/src/lib/server/shopping/repository.ts`, `listShoppingList()`) aggregiert +Zutaten aus allen Warenkorb-Rezepten dynamisch per `GROUP BY LOWER(TRIM(name)), LOWER(TRIM(unit))` +und summiert die skalierten Mengen. Verschiedene Einheiten für dieselbe Zutat bleiben separate +Zeilen — typisches Beispiel: `500 g Kartoffeln` (Rezept A) und `1 kg Kartoffeln` (Rezept B) +erscheinen als zwei Zeilen. Gewünscht: beides konsolidiert zu `1,5 kg Kartoffeln`. + +## Design-Entscheidungen (durch Brainstorming bestätigt) + +- **Scope**: nur Gewicht (g ↔ kg) und Volumen (ml ↔ l). TL/EL/Tasse/Stück bleiben unverändert. +- **Anzeige-Einheit**: Auto-Promote ab ≥ 1000 in Basis-Einheit (500 g + 1 kg → "1,5 kg", + 200 g + 300 g → "500 g", 400 ml + 0,5 l → "900 ml", 0,5 l + 0,8 l → "1,3 l"). +- **Formatter**: `formatQuantity` wechselt app-weit auf `toLocaleString('de-DE', …)` → + deutsches Komma als Dezimaltrennzeichen überall, kein Tausender-Grouping. +- **Check-Stabilität**: der „abgehakt"-State hängt künftig an der Unit-Family (weight / volume / + raw-unit), nicht an einer Display-Einheit, damit Hin-und-her-Wechsel zwischen g und kg den + Haken nicht verlieren. + +## Sektion 1 — Unit-Konsolidierung + +### Neue Utility: `src/lib/server/shopping/unit-consolidation.ts` + +Zwei reine Funktionen, vollständig getestet per Unit-Tests: + +```ts +export type UnitFamily = 'weight' | 'volume' | string; + +const WEIGHT_UNITS = new Set(['g', 'kg']); +const VOLUME_UNITS = new Set(['ml', 'l']); + +export function unitFamily(unit: string | null | undefined): UnitFamily { + const u = (unit ?? '').trim().toLowerCase(); + if (WEIGHT_UNITS.has(u)) return 'weight'; + if (VOLUME_UNITS.has(u)) return 'volume'; + return u; // leer bleibt leer → eigene Gruppe +} + +export interface QuantityInUnit { + quantity: number | null; + unit: string | null; +} + +export function consolidate(rows: QuantityInUnit[]): QuantityInUnit { + // Gewicht: in g summieren, ≥1000 → kg, sonst g + // Volumen: in ml summieren, ≥1000 → l, sonst ml + // Andere: quantity einfach summieren, unit vom ersten Eintrag + // (alle rows einer Gruppe haben dieselbe Family = denselben unit-string) + // quantity=null wird als 0 behandelt (z. B. "etwas Salz" + "1 TL Salz" → "1 TL") +} +``` + +**Rundung Promote-Schwelle**: Vergleich passiert auf summierter Basis-Einheit +(z. B. 1500 g ≥ 1000 → kg). Ergebnis-Rundung: `Math.round(x * 100) / 100` (max. +zwei Nachkommastellen), die finale Display-Formatierung macht `formatQuantity`. + +**Edge-Cases, die expliziter Test-Fall sind**: +- `500 g + 1 kg` → `{quantity: 1.5, unit: 'kg'}` +- `200 g + 300 g` → `{quantity: 500, unit: 'g'}` +- `400 ml + 0.5 l` → `{quantity: 900, unit: 'ml'}` +- `0.5 l + 0.8 l` → `{quantity: 1.3, unit: 'l'}` +- `2 Bund + 1 Bund` → `{quantity: 3, unit: 'Bund'}` (unchanged family) +- `5 Stück + 3 Stück` → `{quantity: 8, unit: 'Stück'}` +- `null + 1 TL Salz` (eine Menge unbekannt) → `{quantity: 1, unit: 'TL'}` +- `null + null` → `{quantity: null, unit: ''}` + +### Integration in `listShoppingList()` + +Die existierende SQL-Query liefert schon skalierte Mengen pro Zutat-Zeile +(quantity * servings / servings_default). Änderung: + +1. **GROUP BY** der SQL-Query wechselt von `LOWER(TRIM(unit))` auf einen + Family-Key (inline per `CASE`): + + ```sql + GROUP BY LOWER(TRIM(name)), + CASE LOWER(TRIM(unit)) + WHEN 'g' THEN 'weight' + WHEN 'kg' THEN 'weight' + WHEN 'ml' THEN 'volume' + WHEN 'l' THEN 'volume' + ELSE LOWER(TRIM(unit)) + END + ``` + +2. **SUM()** wird nicht mehr blind über quantity gerechnet (500 + 1 ≠ 1500 + in Basis). Stattdessen liefert SQL pro Gruppe eine Liste der einzelnen + `(quantity, unit)`-Paare — z. B. via `json_group_array(json_object('quantity', q, 'unit', u))`. + TypeScript ruft dann `consolidate()` pro Zeile auf. + + Alternative: SQL liefert für Familien 'weight' und 'volume' schon die + summierten Basis-Werte (via `SUM(q * CASE WHEN unit='kg' THEN 1000 ELSE 1 END)`), + für andere Families die unveränderte `SUM(q)`. Spart den json_group_array-Trick, + ist aber in SQL hässlich. **Empfehlung**: json_group_array + consolidate in TS — + SQL bleibt lesbar, Logik testbar. + +3. Der Rückgabewert `ShoppingListItem` bekommt zwei zusätzliche Felder (wenn + nicht schon vorhanden): + - `quantity: number | null` (finaler Display-Wert) + - `unit: string | null` (finale Display-Einheit) + - `unitFamilyKey: string` (für den Check-Lookup clientseitig) + +## Sektion 2 — Check-Key-Stabilität + +Aktuelle Tabelle (aus `013_shopping_list.sql`): + +```sql +CREATE TABLE shopping_cart_check ( + name_key TEXT NOT NULL, + unit_key TEXT NOT NULL, + checked_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (name_key, unit_key) +); +``` + +### Neue Migration `015_shopping_check_family.sql` + +```sql +-- Unit-Key wird zum Family-Key: g/kg → 'weight', ml/l → 'volume', sonst lowercased unit. +-- Wir migrieren bestehende Einträge damit alte Abhaks gültig bleiben. +UPDATE shopping_cart_check SET unit_key = 'weight' WHERE unit_key IN ('g', 'kg'); +UPDATE shopping_cart_check SET unit_key = 'volume' WHERE unit_key IN ('ml', 'l'); + +-- Nach Umetikettierung können Duplikate entstehen (z. B. zwei Einträge mit +-- 'weight' für dieselbe Zutat). Deduplizieren: jüngsten behalten. +DELETE FROM shopping_cart_check +WHERE rowid NOT IN ( + SELECT MAX(rowid) + FROM shopping_cart_check + GROUP BY name_key, unit_key +); +``` + +### Code-Änderungen + +- `listShoppingList()`: beim Joinen von `shopping_cart_check` mit den aggregierten + Zeilen matched jetzt `(name_key, unit_family_key)` statt `(name_key, unit_key)`. +- `toggleCheck(name, unit, checked)`: speichert/löscht Check mit + `unitFamily(unit)` statt raw unit. + +## Sektion 3 — Display-Formatter + +### `src/lib/quantity-format.ts` + +```ts +export function formatQuantity(q: number | null): string { + if (q === null || q === undefined) return ''; + return q.toLocaleString('de-DE', { + maximumFractionDigits: 2, + useGrouping: false + }); +} +``` + +Kleinere Datei, dieselbe Semantik (max. 2 Dezimalen, ganze Zahlen ohne Dezimal), +plus deutsches Dezimalkomma app-weit. + +### Test-Anpassung `tests/unit/quantity-format.test.ts` + +Erwartungswerte von `"0.33"` auf `"0,33"` etc. ziehen. Bestehende 5 Tests müssen mit. + +## Sektion 4 — Tests + +### Neu: `tests/unit/unit-consolidation.test.ts` + +Alle Edge-Cases aus Sektion 1 als expect-Assertions. Plus: `unitFamily`-Table-Tests. + +### Ergänzung: `tests/integration/shopping-repository.test.ts` + +Ein neuer `describe`-Block „konsolidiert über Einheiten": +- Rezept A mit `500 g Kartoffeln`, Rezept B mit `1 kg Kartoffeln` → eine Zeile + `{name: 'kartoffeln', quantity: 1.5, unit: 'kg'}`. +- Analog Volumen mit ml + l. +- Gemischte Units wie `2 Bund Petersilie + 1 Bund Petersilie` → eine Zeile `3 Bund`. +- `5 Stück Eier + 500 g Eier` → **zwei** Zeilen (verschiedene Families). +- Abhaken einer konsolidierten kg-Zeile → nach Entfernung eines Rezepts (jetzt nur + noch 800 g) bleibt die Zeile abgehakt (Family = 'weight' stabil). + +### Ergänzung: Migration-Test + +Ein kleiner Test ähnlich dem Stil anderer Migration-Tests im Repo, der verifiziert: +- Alt-Einträge `(milch, 'ml')` und `(milch, 'l')` kollabieren zu einem `(milch, 'volume')`. +- Unveränderte Einträge wie `(petersilie, 'bund')` bleiben. + +## Was explizit NICHT dabei ist (YAGNI) + +- **Fuzzy-Name-Matching** (Kartoffel vs Kartoffeln, „Zwiebeln, rot" vs „rote Zwiebeln") — + ausgeschlossen, hohe Fehlerrate. +- **Stück-zu-Gramm-Mappings** (1 Zwiebel ≈ 80 g) — semantisch fraglich, nicht deterministisch. +- **TL/EL/Tasse-Konvertierung** — Einkauft man nicht in. +- **User-editierbare Custom-Units** — Overkill für eine Familien-PWA. +- **UI-Anzeige der zugrundeliegenden Einzelmengen** („1,5 kg — aus 500 g + 1 kg") — wäre + nett, aber nicht notwendig für die Hauptfunktion. + +## Phase-Gliederung (für die spätere writing-plans-Phase) + +Eine Phase reicht aus: +1. `unit-consolidation.ts` + Unit-Tests +2. `quantity-format.ts` auf `toLocaleString` umbauen + Tests updaten +3. Migration `015_shopping_check_family.sql` +4. `listShoppingList()` integriert Konsolidierung + Check-Join +5. `toggleCheck()` auf Family-Key umstellen +6. Integration-Tests + +Alles in einer Phase, weil Änderungen eng verzahnt sind (Migration + Repository + Formatter +müssen zusammen deployt werden, sonst gibt es UI-Inkonsistenzen).