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