docs(spec): Einkaufsliste Mengen-Konsolidierung ueber Einheiten

Design fuer g/kg + ml/l Konsolidierung in listShoppingList():
500 g + 1 kg Kartoffeln aus verschiedenen Rezepten → 1,5 kg.

- unit-consolidation.ts mit unitFamily + consolidate
- GROUP BY wechselt auf family-key (weight/volume/raw)
- formatQuantity auf toLocaleString('de-DE', ...) app-weit
- Migration 015: shopping_cart_check.unit_key → family-key,
  bestehende Abhaks migriert, Duplikate dedupliziert
- Test-Coverage fuer alle Edge-Cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 16:39:47 +02:00
parent 2f0a45f487
commit b9b06e161c

View File

@@ -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: '<leer oder erster 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).