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:
@@ -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).
|
||||||
Reference in New Issue
Block a user