From fa542e0fb9e4b9f5c47a01b23836b1313fbb2201 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 12 Sep 2026 18:23:01 +0200 Subject: [PATCH] feat: Listen-Dialog mit Mengen/Einheiten, Mitai ohne Kochrezepte Roh-Kombinationen statt Rezept-Sprache; Zutaten suchen, EL/TL/Prise auf Gramm. Gekochte Gerichte und Ausbeute bleiben Tandoor. Co-authored-by: Cursor --- .claude/docs/functional/BLS_FOOD_REFERENCE.md | 24 +- .claude/docs/technical/BLS_FOOD_REFERENCE.md | 9 +- CLAUDE.md | 4 +- backend/data_layer/food_knowledge.py | 2 + backend/data_layer/food_mapping.py | 78 +++ backend/data_layer/food_recipes.py | 84 ++- .../066_recipe_ingredient_units.sql | 18 + backend/routers/bls.py | 6 + backend/routers/nutrition.py | 3 + backend/tests/test_food_mapping.py | 16 + backend/version.py | 9 +- docs/issues/issue-bls-food-mapping.md | 5 +- frontend/src/components/FoodRecipeDialog.jsx | 477 ++++++++++++++++++ frontend/src/components/FoodRecipeEditor.jsx | 144 +----- frontend/src/components/FoodSearchModal.jsx | 22 +- frontend/src/components/NutritionFoodMap.jsx | 77 +-- frontend/src/utils/api.js | 1 + 17 files changed, 776 insertions(+), 203 deletions(-) create mode 100644 backend/migrations/066_recipe_ingredient_units.sql create mode 100644 frontend/src/components/FoodRecipeDialog.jsx diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index df89171..0d6ce3e 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -8,11 +8,27 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte ## Zuordnung (UX) -Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Rezept** in derselben Zeile (oder im Such-Popup). Ein Lebensmittel wird ein Katalogeintrag. Ein Rezept bekommt Zutaten (Name + Gramm): schon zugeordnete Namen werden übernommen, offene Zutaten erscheinen danach in der Offene-Liste (Kennzeichnung „Rezeptzutat“) und lassen sich dort nur als Lebensmittel zuordnen. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit, nicht über alle offenen Namen auf einmal. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“ und müssen nicht gemappt werden. +Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“. -## FDDB-Listen / eigene Rezepte +## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor) -FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Rezeptname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Rezept verknüpft → **Zutaten** zuordnen, nicht das Rezept als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück. +**Mitai speichert nur Roh-Kombinationen** — einen Namen für eine Zutatenliste ohne Kochvorgang (Müsli, Bowl, Smoothie). Summe der Zutatengramm = gegessene Gramm. Tabelle bleibt `food_recipes` (kein Rename). + +**Gekochte Familienrezepte** liegen in **Tandoor**. Kein zweites Rezeptbuch in Mitai. Kochschwund (Wasserverlust beim Köcheln) darf die Rohzutaten nicht 1:1 auf die gegessene Menge skalieren. + +Spätere Ausbeute (nicht in Phase 1): + +- `ingredients_g` = Summe der auf Gramm aufgelösten Zutaten +- `cooked_yield_g` = gewogenes Fertiggewicht nach dem Kochen +- `yield_factor = cooked_yield_g / ingredients_g` (oft 0,5–0,9 bei langem Köcheln) +- Nährwerte **pro 100 g fertig** = `Summe(Zutat_Nährwerte) / cooked_yield_g × 100` +- Gegessen: `portion_g / 100 × Werte_pro_100g` + +Ohne Fertiggewicht: nicht raten — FDDB-Makros oder Marke „unvollständig“. Für Listen gilt implizit `cooked_yield_g = ingredients_g` (Faktor 1). Tandoor liefert Zutaten und Schritte; die Ausbeute und die **pro-100-g**-Rechnung führt Mitai. Importiertes Gericht wird ein Katalogeintrag (`catalog_kind` z. B. `recipe_cooked`), keine zweite Wertetabelle. + +## FDDB-Listen + +FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Listenname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Liste verknüpft → **Zutaten** zuordnen, nicht die Liste als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück. Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein. @@ -35,3 +51,5 @@ Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzl ## Später Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge). + +**Tandoor:** Familienrezepte bleiben dort. Später: Import/Verknüpfung, Zutaten gegen Mitai-Mappings, `cooked_yield_g`, Katalogeintrag Fertiggericht pro 100 g. Kein Connector in Phase 1. diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index acc3d42..029e6cc 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -11,13 +11,13 @@ - `nutrition_items` — Tagebuchzeilen inkl. `logged_at` - `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute - `nutrition_day_marks` — `fasting` | `incomplete` -- `food_recipes` / `food_recipe_ingredients` — FDDB-Listen; `nutrition_items.recipe_id` +- `food_recipes` / `food_recipe_ingredients` — Mitai-Listen/Kombinationen (Rohmischung, kein Kochschwund); `nutrition_items.recipe_id`. Kein Tabellen-Rename. Gekochte Gerichte später Tandoor + `cooked_yield_g`, nicht hier. ## Layer 1 - `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete - `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes` -- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Rezept-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen) +- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Listen-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen). Diese Skala gilt nur für Roh-Kombinationen, nicht für gekochte Gerichte. - `bls/recipe_parser.py` — `produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben) ## Import @@ -35,8 +35,9 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve - `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply` - `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln -- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping -- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). **Neu anlegen** fragt zuerst Lebensmittel vs. Rezept; Rezept-Zutaten ohne Mapping erscheinen als `kind=recipe_ingredient` in Unmapped. +- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping +- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat). Tandoor-Import und `cooked_yield_g` später, kein Connector in Phase 1. +- Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g). - `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80) - Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar) - `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**) diff --git a/CLAUDE.md b/CLAUDE.md index f6a29a6..a11c868 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,12 +121,12 @@ frontend/src/ - **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil. - **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings. -- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich. +- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Kombinationen; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich. - **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. - **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“. - **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten. - **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. -- **Rezepte:** Nutzer-CRUD `POST/PUT/DELETE /nutrition/recipes`. Beim Zuordnen zuerst **Lebensmittel oder Rezept**; Rezept legt Zutaten an — bereits gemappte werden übernommen, offene erscheinen in der Liste. Admin-Mappings: `PUT /admin/food-mappings/{id}` zum direkten Wechsel. +- **Listen / Kombinationen (Mitai):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. Gekochte Familienrezepte: Tandoor + später `cooked_yield_g` / Fertiggericht pro 100 g. Admin-Mappings: `PUT /admin/food-mappings/{id}`. - **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. diff --git a/backend/data_layer/food_knowledge.py b/backend/data_layer/food_knowledge.py index 0abbbbe..98ec49f 100644 --- a/backend/data_layer/food_knowledge.py +++ b/backend/data_layer/food_knowledge.py @@ -168,6 +168,8 @@ def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]: "source_name_normalized": ing.get("source_name_normalized"), "quantity_raw": ing.get("quantity_raw"), "quantity_g": _jsonable(ing.get("quantity_g")), + "quantity_amount": _jsonable(ing.get("quantity_amount")), + "source_unit": ing.get("source_unit"), "sort_order": ing.get("sort_order") or 0, } for ing in rec.get("ingredients") or [] diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index f206ba5..e76fb79 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -132,6 +132,16 @@ UNIT_ALIASES = { MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0} DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0} COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"}) +UNIT_LABELS = { + "g": "g", "kg": "kg", "ml": "ml", "l": "l", + "el": "EL", "tl": "TL", "prise": "Prise", "msp": "Msp.", + "stück": "Stück", "scheibe": "Scheibe", "portion": "Portion", + "becher": "Becher", "tasse": "Tasse", +} +UNIT_ORDER = ( + "g", "ml", "el", "tl", "prise", "msp", + "stück", "scheibe", "portion", "becher", "tasse", "kg", "l", +) QTY_PARSE_RE = re.compile( r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$", re.IGNORECASE, @@ -186,6 +196,74 @@ def detect_quantity_unit(*texts: str | None) -> str | None: return None +def format_quantity_raw(value: float | None, unit: str | None) -> str | None: + if value is None: + return None + label = UNIT_LABELS.get(unit or "g", unit or "g") + num = int(value) if float(value) == int(value) else value + return f"{num} {label}".replace(".", ",") + + +def list_quantity_units() -> list[dict[str, Any]]: + out = [] + for uid in UNIT_ORDER: + default_g = MASS_VOLUME_TO_G.get(uid) + if default_g is None: + default_g = DEFAULT_UNIT_G.get(uid) + out.append({ + "id": uid, + "label": UNIT_LABELS[uid], + "default_g": default_g, + "needs_unit_map": uid in COUNT_UNITS, + }) + return out + + +def resolve_quantity( + *, + quantity_raw: str | None = None, + quantity_amount: float | None = None, + source_unit: str | None = None, + quantity_g: float | None = None, + grams_per_unit: float | None = None, +) -> dict[str, Any]: + """Amount + unit → quantity_g. Standard unit is grams; conversion uses mapping or defaults.""" + amount = None + if quantity_amount not in (None, ""): + try: + amount = float(quantity_amount) + except (TypeError, ValueError): + amount = None + explicit_g = None + if quantity_g not in (None, ""): + try: + explicit_g = float(quantity_g) + except (TypeError, ValueError): + explicit_g = None + if amount is not None: + unit = _canon_unit(source_unit) or "g" + raw = format_quantity_raw(amount, unit) + parsed = parse_quantity(raw.replace(",", "."), grams_per_unit) + elif quantity_raw: + parsed = parse_quantity(quantity_raw, grams_per_unit) + elif explicit_g is not None: + parsed = {"value": explicit_g, "unit": "g", "quantity_g": explicit_g, "needs_unit_map": False} + else: + parsed = {"value": None, "unit": _canon_unit(source_unit), "quantity_g": None, "needs_unit_map": False} + qty_g = parsed.get("quantity_g") + if qty_g is None and explicit_g is not None: + qty_g = explicit_g + unit = parsed.get("unit") + value = parsed.get("value") + return { + "quantity_amount": value, + "source_unit": unit, + "quantity_raw": format_quantity_raw(value, unit) or (str(quantity_raw).strip() if quantity_raw else None), + "quantity_g": qty_g, + "needs_unit_map": bool(parsed.get("needs_unit_map")), + } + + def get_food_mapping_with_cursor( cur, source_name: str, diff --git a/backend/data_layer/food_recipes.py b/backend/data_layer/food_recipes.py index bd46098..c7fdd8a 100644 --- a/backend/data_layer/food_recipes.py +++ b/backend/data_layer/food_recipes.py @@ -4,10 +4,32 @@ from __future__ import annotations import uuid from typing import Any -from data_layer.food_mapping import get_food_mapping_with_cursor, normalize_food_name +from data_layer.food_mapping import ( + get_food_mapping_with_cursor, + normalize_food_name, + resolve_quantity, +) from data_layer.nutrition_items import catalog_macros_for_item, _f +def _resolved_ingredient_qty(cur, profile_id: str, source_name_raw: str, ing: dict[str, Any]) -> dict[str, Any]: + mapping = get_food_mapping_with_cursor(cur, source_name_raw, profile_id) + gpu = ing.get("grams_per_unit") + if gpu in (None, ""): + gpu = mapping.get("grams_per_unit") if mapping else None + try: + gpu = float(gpu) if gpu not in (None, "") else None + except (TypeError, ValueError): + gpu = None + return resolve_quantity( + quantity_raw=ing.get("quantity_raw"), + quantity_amount=ing.get("quantity_amount"), + source_unit=ing.get("source_unit"), + quantity_g=ing.get("quantity_g"), + grams_per_unit=gpu, + ) + + def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]: inserted = updated = ingredients = 0 for rec in recipes: @@ -46,16 +68,19 @@ def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[ inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw")) if not inorm: continue + raw_name = ing.get("source_name_raw") or inorm + qty = _resolved_ingredient_qty(cur, profile_id, raw_name, ing) cur.execute( """ INSERT INTO food_recipe_ingredients (id, recipe_id, source_name_raw, source_name_normalized, - quantity_raw, quantity_g, sort_order) - VALUES (%s,%s,%s,%s,%s,%s,%s) + quantity_raw, quantity_g, quantity_amount, source_unit, sort_order) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) """, ( - str(uuid.uuid4()), rid, ing["source_name_raw"], inorm, - ing.get("quantity_raw"), ing.get("quantity_g"), ing.get("sort_order") or 0, + str(uuid.uuid4()), rid, raw_name, inorm, + qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], + ing.get("sort_order") or 0, ), ) ingredients += 1 @@ -111,7 +136,8 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]: ids = [str(r["id"]) for r in recipes] cur.execute( """ - SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order + SELECT recipe_id, source_name_raw, source_name_normalized, + quantity_raw, quantity_g, quantity_amount, source_unit, sort_order FROM food_recipe_ingredients WHERE recipe_id = ANY(%s::uuid[]) ORDER BY sort_order, source_name_raw @@ -195,19 +221,18 @@ def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None inorm = ing.get("source_name_normalized") or normalize_food_name(raw) if not inorm: continue - qty = ing.get("quantity_g") - try: - qty = float(qty) if qty not in (None, "") else None - except (TypeError, ValueError): - qty = None + qty = _resolved_ingredient_qty(cur, profile_id, raw or inorm, ing) cur.execute( """ INSERT INTO food_recipe_ingredients (id, recipe_id, source_name_raw, source_name_normalized, - quantity_raw, quantity_g, sort_order) - VALUES (%s,%s,%s,%s,%s,%s,%s) + quantity_raw, quantity_g, quantity_amount, source_unit, sort_order) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) """, - (str(uuid.uuid4()), rid, raw or inorm, inorm, ing.get("quantity_raw"), qty, i), + ( + str(uuid.uuid4()), rid, raw or inorm, inorm, + qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i, + ), ) link_recipes_to_items(cur, profile_id) saved = get_recipe(cur, profile_id, rid) @@ -249,7 +274,7 @@ def mapped_ingredient_quantities( return None cur.execute( """ - SELECT source_name_raw, quantity_g + SELECT source_name_raw, quantity_g, quantity_raw, quantity_amount, source_unit FROM food_recipe_ingredients WHERE recipe_id = %s ORDER BY sort_order @@ -259,22 +284,29 @@ def mapped_ingredient_quantities( ings = cur.fetchall() if not ings: return None - total_g = sum(_f(i.get("quantity_g")) for i in ings) + resolved_ings = [] + for ing in ings: + mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id) + if not mapping: + return None + qty = resolve_quantity( + quantity_raw=ing.get("quantity_raw"), + quantity_amount=ing.get("quantity_amount"), + source_unit=ing.get("source_unit"), + quantity_g=ing.get("quantity_g"), + grams_per_unit=mapping.get("grams_per_unit"), + ) + grams = _f(qty.get("quantity_g")) + if grams <= 0: + return None + resolved_ings.append({"food_id": mapping["food_id"], "quantity_g": grams}) + total_g = sum(i["quantity_g"] for i in resolved_ings) if eaten_qty_g and eaten_qty_g > 0 and total_g > 0: scale = float(eaten_qty_g) / total_g else: portions = float(rec.get("portions") or 1) or 1.0 scale = 1.0 / portions - out = [] - for ing in ings: - mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id) - if not mapping: - return None - out.append({ - "food_id": mapping["food_id"], - "quantity_g": _f(ing.get("quantity_g")) * scale, - }) - return out + return [{**i, "quantity_g": i["quantity_g"] * scale} for i in resolved_ings] def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None: diff --git a/backend/migrations/066_recipe_ingredient_units.sql b/backend/migrations/066_recipe_ingredient_units.sql new file mode 100644 index 0000000..29b66cd --- /dev/null +++ b/backend/migrations/066_recipe_ingredient_units.sql @@ -0,0 +1,18 @@ +-- Menge + Einheit an Rezeptzutaten; quantity_g bleibt die Standardeinheit (Gramm). + +ALTER TABLE food_recipe_ingredients + ADD COLUMN IF NOT EXISTS quantity_amount NUMERIC(10,3), + ADD COLUMN IF NOT EXISTS source_unit VARCHAR(20); + +COMMENT ON COLUMN food_recipe_ingredients.quantity_amount IS 'Menge in source_unit (z. B. 2 TL)'; +COMMENT ON COLUMN food_recipe_ingredients.source_unit IS 'Kanonische Einheit: g, ml, el, tl, prise, stück, …'; +COMMENT ON COLUMN food_recipe_ingredients.quantity_g IS 'Umrechnung auf Gramm, wenn Faktor bekannt'; + +UPDATE food_recipe_ingredients +SET quantity_amount = quantity_g, source_unit = 'g' +WHERE quantity_g IS NOT NULL AND quantity_amount IS NULL AND source_unit IS NULL; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 066: recipe ingredient amount + unit'; +END $$; diff --git a/backend/routers/bls.py b/backend/routers/bls.py index 13dc46a..8ea6384 100644 --- a/backend/routers/bls.py +++ b/backend/routers/bls.py @@ -13,6 +13,7 @@ from data_layer.food_mapping import ( apply_mapping_to_items, apply_quantities_to_items, clear_mapping_from_items, + list_quantity_units, normalize_food_name, upsert_food_mapping, ) @@ -79,6 +80,11 @@ def _schedule_rebuild(profile_id: str, dates: list[str], context: str) -> None: ).start() +@router.get("/units") +def list_food_units(session: dict = Depends(require_auth)): + return list_quantity_units() + + @router.get("/foods") def search_foods( q: str = "", diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 76a7b2d..18d9d2f 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -28,6 +28,9 @@ class RecipeIngredientIn(BaseModel): source_name_raw: str quantity_raw: Optional[str] = None quantity_g: Optional[float] = None + quantity_amount: Optional[float] = None + source_unit: Optional[str] = None + grams_per_unit: Optional[float] = None class RecipeIn(BaseModel): diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index c9c03b5..75377d4 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -3,11 +3,13 @@ from datetime import date from csv_parser.executor import guess_nutrition_item_fields from data_layer.food_mapping import ( filter_unmapped_since, + list_quantity_units, merge_unmapped_rows, normalize_food_name, parse_quantity, parse_quantity_g, primary_search_query, + resolve_quantity, ) from data_layer.nutrition_items import macros_differ @@ -46,6 +48,20 @@ def test_parse_quantity_g(): assert parse_quantity("2 EL")["unit"] == "el" +def test_resolve_quantity_household_units(): + two_tl = resolve_quantity(quantity_amount=2, source_unit="tl") + assert two_tl["quantity_g"] == 10.0 + assert two_tl["quantity_raw"] == "2 TL" + pinch = resolve_quantity(quantity_amount=1, source_unit="prise") + assert pinch["quantity_g"] == 0.3 + slice_ = resolve_quantity(quantity_amount=1, source_unit="stück") + assert slice_["quantity_g"] is None + assert slice_["needs_unit_map"] is True + assert resolve_quantity(quantity_amount=1, source_unit="stück", grams_per_unit=60)["quantity_g"] == 60.0 + ids = [u["id"] for u in list_quantity_units()] + assert "tl" in ids and "prise" in ids + + def test_guess_fddb_bezeichnung_without_template_mapping(): name, qty = guess_nutrition_item_fields({ "bezeichnung": "50 g Hähnchen", diff --git a/backend/version.py b/backend/version.py index 8973af8..1e174e2 100644 --- a/backend/version.py +++ b/backend/version.py @@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH APP_VERSION = "0.9v" BUILD_DATE = "2026-09-12" -DB_SCHEMA_VERSION = "20260912b" # 065 omega attribute extensions +DB_SCHEMA_VERSION = "20260912c" # 066 recipe ingredient units MODULE_VERSIONS = { "auth": "1.2.0", @@ -20,8 +20,8 @@ MODULE_VERSIONS = { "circumference": "1.0.1", "caliper": "1.0.1", "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement - "nutrition": "1.2.9", # Zuordnen: Lebensmittel oder Rezept anlegen - "bls": "1.0.4", + "nutrition": "1.3.2", # UI: Liste/Kombination; Tandoor-Gerichte später + "bls": "1.0.5", # GET /bls/units "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", @@ -55,6 +55,9 @@ CHANGELOG = [ "Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%", "Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g", "Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern", + "Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später", + "Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm", + "Zuordnen: Mitai = Liste/Kombination; gekochte Rezepte Tandoor + Ausbeute später", ], }, { diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md index 1f56c00..8194c63 100644 --- a/docs/issues/issue-bls-food-mapping.md +++ b/docs/issues/issue-bls-food-mapping.md @@ -15,7 +15,10 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende - Fasten-/Lücken-Marken unabhängig vom Import - Einzelerfassung nur Makros unverändert - Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer) -- FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen +- FDDB-Listen-CSV importieren; Tagebuch-Listenzeilen in Zutaten auflösen - Zuordnungen und Listen als JSON zwischen Instanzen übertragen - Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping - Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung +- Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination +- Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück) +- Folge: Tandoor-Gerichte + `cooked_yield_g` (Fertiggewicht) + Katalogeintrag pro 100 g — nicht Phase 1 diff --git a/frontend/src/components/FoodRecipeDialog.jsx b/frontend/src/components/FoodRecipeDialog.jsx new file mode 100644 index 0000000..d1a0b4e --- /dev/null +++ b/frontend/src/components/FoodRecipeDialog.jsx @@ -0,0 +1,477 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../utils/api' + +const FALLBACK_UNITS = [ + { id: 'g', label: 'g', default_g: 1, needs_unit_map: false }, + { id: 'ml', label: 'ml', default_g: 1, needs_unit_map: false }, + { id: 'el', label: 'EL', default_g: 15, needs_unit_map: false }, + { id: 'tl', label: 'TL', default_g: 5, needs_unit_map: false }, + { id: 'prise', label: 'Prise', default_g: 0.3, needs_unit_map: false }, + { id: 'msp', label: 'Msp.', default_g: 1, needs_unit_map: false }, + { id: 'stück', label: 'Stück', default_g: null, needs_unit_map: true }, + { id: 'scheibe', label: 'Scheibe', default_g: null, needs_unit_map: true }, + { id: 'portion', label: 'Portion', default_g: null, needs_unit_map: true }, + { id: 'becher', label: 'Becher', default_g: null, needs_unit_map: true }, + { id: 'tasse', label: 'Tasse', default_g: null, needs_unit_map: true }, + { id: 'kg', label: 'kg', default_g: 1000, needs_unit_map: false }, + { id: 'l', label: 'l', default_g: 1000, needs_unit_map: false }, +] + +function emptyIng(overrides = {}) { + return { + source_name_raw: '', + amount: '', + unit: 'g', + gramsPerUnit: '', + foodId: null, + mappedLabel: '', + ...overrides, + } +} + +function unitMeta(units, id) { + return (units || []).find((u) => u.id === id) || FALLBACK_UNITS.find((u) => u.id === id) +} + +function gramsHint(ing, units) { + const amount = parseFloat(String(ing.amount).replace(',', '.')) + if (!Number.isFinite(amount) || amount <= 0) return null + const meta = unitMeta(units, ing.unit) + const factor = parseFloat(String(ing.gramsPerUnit).replace(',', '.')) + const per = Number.isFinite(factor) && factor > 0 ? factor : meta?.default_g + if (!per) return null + const g = Math.round(amount * per * 1000) / 1000 + return g +} + +function fromSavedIngredient(i, mapped) { + let amount = i.quantity_amount + let unit = i.source_unit || 'g' + if (amount == null && i.quantity_raw) { + const m = String(i.quantity_raw).trim().match(/^(\d+(?:[.,]\d+)?)\s*(.*)$/) + if (m) { + amount = m[1].replace(',', '.') + const label = (m[2] || '').trim().toLowerCase() + const hit = FALLBACK_UNITS.find((u) => u.id === label || u.label.toLowerCase() === label) + if (hit) unit = hit.id + } + } + if (amount == null && i.quantity_g != null) { + amount = i.quantity_g + unit = i.source_unit || 'g' + } + return emptyIng({ + source_name_raw: i.source_name_raw || '', + amount: amount ?? '', + unit, + gramsPerUnit: mapped?.grams_per_unit ?? '', + foodId: mapped?.food_id || null, + mappedLabel: mapped?.food_name_de || '', + }) +} + +function mappingFor(name, learned) { + const n = (name || '').trim().toLowerCase() + if (!n) return null + return (learned || []).find((m) => { + const raw = (m.source_name_raw || '').toLowerCase() + const norm = (m.source_name_normalized || '').toLowerCase() + return raw === n || norm === n + }) || null +} + +function filterLearned(term, learned) { + const t = (term || '').trim().toLowerCase() + if (t.length < 2) return [] + return (learned || []).filter((m) => { + const hay = `${m.source_name_raw || ''} ${m.food_name_de || ''} ${m.bls_code || ''}`.toLowerCase() + return hay.includes(t) + }).slice(0, 8) +} + +export default function FoodRecipeDialog({ + title, + defaultName, + defaultPortions, + defaultIngredients, + learned, + disabled, + submitLabel, + onSave, + onClose, +}) { + const [name, setName] = useState(defaultName || '') + const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1') + const [ings, setIngs] = useState( + (defaultIngredients || []).length + ? defaultIngredients.map((i) => fromSavedIngredient(i, mappingFor(i.source_name_raw, learned))) + : [] + ) + const [units, setUnits] = useState(FALLBACK_UNITS) + const [q, setQ] = useState('') + const [catalogHits, setCatalogHits] = useState([]) + const [searching, setSearching] = useState(false) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [localLearned, setLocalLearned] = useState(learned || []) + const nameRef = useRef(null) + const qtyRefs = useRef({}) + const timer = useRef(null) + const abortRef = useRef(null) + const seqRef = useRef(0) + + useEffect(() => { setLocalLearned(learned || []) }, [learned]) + + useEffect(() => { + api.listFoodUnits().then((list) => { + if (Array.isArray(list) && list.length) setUnits(list) + }).catch(() => {}) + }, []) + + useEffect(() => { + nameRef.current?.focus() + const onKey = (e) => { if (e.key === 'Escape' && !saving) onClose() } + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('keydown', onKey) + clearTimeout(timer.current) + abortRef.current?.abort() + } + }, []) + + const mappedHits = filterLearned(q, localLearned) + const mappedNames = new Set(mappedHits.map((m) => (m.food_name_de || '').toLowerCase())) + const catalogOnly = catalogHits.filter((h) => !mappedNames.has((h.name_de || '').toLowerCase())) + + const runCatalog = async (term) => { + const query = (term || '').trim() + abortRef.current?.abort() + if (query.length < 2) { + setCatalogHits([]) + setSearching(false) + return + } + const seq = ++seqRef.current + const ac = new AbortController() + abortRef.current = ac + setSearching(true) + try { + const next = await api.searchBlsFoods(query, 12, ac.signal) + if (seq !== seqRef.current) return + setCatalogHits(Array.isArray(next) ? next : []) + } catch (e) { + if (e.name === 'AbortError') return + if (seq !== seqRef.current) return + setCatalogHits([]) + } finally { + if (seq === seqRef.current) setSearching(false) + } + } + + const onSearch = (value) => { + setQ(value) + clearTimeout(timer.current) + if (value.trim().length < 2) { + abortRef.current?.abort() + setCatalogHits([]) + setSearching(false) + return + } + timer.current = setTimeout(() => runCatalog(value), 300) + } + + const focusQty = (idx) => { + setTimeout(() => qtyRefs.current[idx]?.focus(), 30) + } + + const addIngredient = (ing) => { + const raw = (ing.source_name_raw || '').trim() + if (!raw) return + setIngs((list) => { + const exists = list.findIndex((x) => (x.source_name_raw || '').trim().toLowerCase() === raw.toLowerCase()) + if (exists >= 0) { + focusQty(exists) + return list + } + const next = [...list, emptyIng({ ...ing, source_name_raw: raw })] + focusQty(next.length - 1) + return next + }) + setQ('') + setCatalogHits([]) + setError(null) + } + + const addMapped = (m) => addIngredient({ + source_name_raw: m.source_name_raw, + foodId: m.food_id, + mappedLabel: m.food_name_de || '', + unit: m.source_unit || 'g', + gramsPerUnit: m.grams_per_unit ?? '', + }) + + const addCatalog = (h) => addIngredient({ + source_name_raw: h.name_de, + foodId: h.id, + mappedLabel: h.name_de, + }) + + const addFreeText = () => { + const raw = q.trim() + if (!raw) return + addIngredient({ source_name_raw: raw }) + } + + const save = async () => { + const ingredients = ings + .map((i) => { + const amount = i.amount === '' ? null : parseFloat(String(i.amount).replace(',', '.')) + const gpu = i.gramsPerUnit === '' ? null : parseFloat(String(i.gramsPerUnit).replace(',', '.')) + return { + source_name_raw: (i.source_name_raw || '').trim(), + quantity_amount: Number.isFinite(amount) ? amount : null, + source_unit: i.unit || 'g', + grams_per_unit: Number.isFinite(gpu) && gpu > 0 ? gpu : null, + foodId: i.foodId, + } + }) + .filter((i) => i.source_name_raw) + if (!name.trim()) { + setError('Listenname fehlt') + return + } + if (!ingredients.length) { + setError('Mindestens eine Zutat angeben') + return + } + if (ingredients.some((i) => i.quantity_amount == null || i.quantity_amount <= 0)) { + setError('Jede Zutat braucht eine Menge') + return + } + setSaving(true) + setError(null) + try { + const nextLearned = [...localLearned] + for (const ing of ingredients) { + if (!ing.foodId) continue + const already = nextLearned.some((m) => ( + (m.source_name_raw || '').trim().toLowerCase() === ing.source_name_raw.toLowerCase() + )) + if (already && !ing.grams_per_unit) continue + const res = await api.upsertMyFoodMapping({ + source_name: ing.source_name_raw, + food_id: ing.foodId, + grams_per_unit: ing.grams_per_unit, + source_unit: ing.source_unit, + }) + nextLearned.unshift({ + id: res.mapping_id, + source_name_raw: ing.source_name_raw, + food_id: ing.foodId, + food_name_de: res.food_name_de || ing.source_name_raw, + }) + } + setLocalLearned(nextLearned) + await onSave({ + name_raw: name.trim(), + portions: parseFloat(String(portions).replace(',', '.')) || 1, + ingredients: ingredients.map(({ source_name_raw, quantity_amount, source_unit, grams_per_unit }) => ({ + source_name_raw, + quantity_amount, + source_unit, + grams_per_unit, + })), + }) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + const showResults = q.trim().length >= 2 + + return ( +
+
+
+

{title || 'Liste'}

+ +
+ +
+

+ Roh-Kombination ohne Kochen (z. B. Müsli). Zutaten suchen, Menge und Einheit angeben. Gramm ist die Standardeinheit — EL, TL, Prise usw. werden umgerechnet, sobald der Faktor bekannt ist. Gekochte Gerichte bleiben in Tandoor. +

+ {error &&
{error}
} + + + setName(e.target.value)} + /> + + setPortions(e.target.value)} + /> + +

Zutat suchen

+ onSearch(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addFreeText() } }} + /> + {showResults && ( +
+ {mappedHits.length > 0 && ( +
Bereits zugeordnet
+ )} + {mappedHits.map((m) => ( + + ))} + {searching &&

Katalog…

} + {catalogOnly.length > 0 && ( +
Katalog
+ )} + {catalogOnly.map((h) => ( + + ))} + +
+ )} + +

Zutaten ({ings.length})

+ {ings.length === 0 && ( +

Noch keine Zutaten — oben suchen und übernehmen.

+ )} + {ings.map((ing, i) => { + const mapped = mappingFor(ing.source_name_raw, localLearned) || (ing.foodId ? { food_name_de: ing.mappedLabel } : null) + const meta = unitMeta(units, ing.unit) + const approx = gramsHint(ing, units) + const needsFactor = Boolean(meta?.needs_unit_map) && !approx + const isMass = ['g', 'ml', 'kg', 'l'].includes(ing.unit) + return ( +
+
+ setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value, foodId: null, mappedLabel: '' } : x))} + /> + +
+
+ { qtyRefs.current[i] = el }} + className="form-input" + type="number" + min="0" + step="0.1" + style={{ textAlign: 'left' }} + placeholder="Menge" + value={ing.amount} + onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, amount: e.target.value } : x))} + /> + +
+ {!isMass && ( + + )} +
+ {approx != null ? `≈ ${String(approx).replace('.', ',')} g` : (needsFactor ? 'Gramm pro Einheit fehlt — später umrechenbar' : '')} + {mapped ? ` · zugeordnet: ${mapped.food_name_de || ing.mappedLabel}` : ' · noch offen — nach dem Speichern in der Liste zuordnen'} +
+
+ ) + })} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/FoodRecipeEditor.jsx b/frontend/src/components/FoodRecipeEditor.jsx index 16475b6..18a3d7b 100644 --- a/frontend/src/components/FoodRecipeEditor.jsx +++ b/frontend/src/components/FoodRecipeEditor.jsx @@ -1,118 +1,20 @@ import { useState } from 'react' import { api } from '../utils/api' - -function emptyIng() { - return { source_name_raw: '', quantity_g: '' } -} - -function mappingHint(name, learned) { - const n = (name || '').trim().toLowerCase() - if (!n) return null - return (learned || []).find((m) => { - const raw = (m.source_name_raw || '').toLowerCase() - const norm = (m.source_name_normalized || '').toLowerCase() - return raw === n || norm === n || raw.includes(n) || n.includes(norm) - }) || null -} - -export function RecipeForm({ defaultName, defaultPortions, defaultIngredients, learned, disabled, submitLabel, onSave, onCancel }) { - const [name, setName] = useState(defaultName || '') - const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1') - const [ings, setIngs] = useState( - (defaultIngredients || []).length - ? defaultIngredients.map((i) => ({ source_name_raw: i.source_name_raw || '', quantity_g: i.quantity_g ?? '' })) - : [emptyIng()] - ) - const [error, setError] = useState(null) - const [saving, setSaving] = useState(false) - - const save = async () => { - const ingredients = ings - .map((i) => ({ - source_name_raw: (i.source_name_raw || '').trim(), - quantity_g: i.quantity_g === '' ? null : parseFloat(String(i.quantity_g).replace(',', '.')), - })) - .filter((i) => i.source_name_raw) - if (!name.trim()) { - setError('Rezeptname fehlt') - return - } - if (!ingredients.length) { - setError('Mindestens eine Zutat angeben') - return - } - setSaving(true) - setError(null) - try { - await onSave({ - name_raw: name.trim(), - portions: parseFloat(String(portions).replace(',', '.')) || 1, - ingredients, - }) - } catch (e) { - setError(e.message) - } finally { - setSaving(false) - } - } - - return ( -
-

- Zutaten, die schon zugeordnet sind, werden übernommen. Offene erscheinen danach in der Liste zum Zuordnen. -

- {error &&
{error}
} - setName(e.target.value)} /> - -

Zutaten (Name + Gramm)

- {ings.map((ing, i) => { - const mapped = mappingHint(ing.source_name_raw, learned) - return ( -
-
- setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value } : x))} /> - setIngs((list) => list.map((x, j) => j === i ? { ...x, quantity_g: e.target.value } : x))} /> -
- {ing.source_name_raw.trim() && ( -
- {mapped ? `zugeordnet: ${mapped.food_name_de || mapped.source_name_raw}` : 'noch offen — nach dem Speichern in der Liste zuordnen'} -
- )} -
- ) - })} - -
- {onCancel && ( - - )} - -
-
- ) -} +import FoodRecipeDialog from './FoodRecipeDialog' export default function FoodRecipeEditor({ recipes, learned, onChanged }) { const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) - const [draft, setDraft] = useState(null) const [error, setError] = useState(null) const startNew = () => { setEditing('new') - setDraft({ name: '', portions: '1' }) setError(null) setOpen(true) } const startEdit = (r) => { setEditing(r) - setDraft(r) setError(null) setOpen(true) } @@ -121,12 +23,11 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) { if (editing === 'new') await api.createNutritionRecipe(body) else await api.updateNutritionRecipe(editing.id, body) setEditing(null) - setDraft(null) onChanged?.() } const remove = async (id) => { - if (!confirm('Rezept wirklich löschen?')) return + if (!confirm('Liste wirklich löschen?')) return try { await api.deleteNutritionRecipe(id) if (editing && editing !== 'new' && editing.id === id) setEditing(null) @@ -139,7 +40,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) { return (
-

Eigene Rezepte / Listen ({recipes.length})

+

Eigene Listen / Kombinationen ({recipes.length})

@@ -147,7 +48,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) { {open && (
{error &&
{error}
} - + {recipes.map((r) => (
{r.name_raw}
@@ -161,23 +62,30 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
))} - {editing === 'new' && ( - setEditing(null)} /> - )} - {editing && editing !== 'new' && ( - setEditing(null)} - /> - )}
)} + {editing === 'new' && ( + setEditing(null)} + /> + )} + {editing && editing !== 'new' && ( + setEditing(null)} + /> + )}
) } diff --git a/frontend/src/components/FoodSearchModal.jsx b/frontend/src/components/FoodSearchModal.jsx index 143c46b..919ef2c 100644 --- a/frontend/src/components/FoodSearchModal.jsx +++ b/frontend/src/components/FoodSearchModal.jsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' -import { RecipeForm } from './FoodRecipeEditor' const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse']) const UNIT_LABEL = { @@ -26,7 +25,7 @@ function detectUnit(...texts) { return null } -export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, learned, onSelect, onCreateRecipe, onClose }) { +export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose }) { const [q, setQ] = useState(initialQuery || '') const [hits, setHits] = useState([]) const [loading, setLoading] = useState(false) @@ -210,30 +209,21 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all {!showCreate ? ( ) : !createKind ? (

Was möchtest du anlegen?

-

Lebensmittel = ein Katalogeintrag. Rezept = mehrere Zutaten; offene Zutaten erscheinen danach in der Liste.

+

Lebensmittel = ein Katalogeintrag. Liste = Roh-Kombination (z. B. Müsli) mit Zutatensuche.

- +
- ) : createKind === 'recipe' ? ( - setCreateKind(null)} - onSave={onCreateRecipe} - /> ) : (

Eigener Eintrag

@@ -253,7 +243,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all setServingG={setServingG} />
- {allowRecipe && onCreateRecipe && ( + {allowRecipe && onWantRecipe && ( )}
- setQ(e.target.value)} /> + setQ(e.target.value)} />
- {filtered.length === 0 &&

Kein passendes Rezept.

} + {filtered.length === 0 &&

Keine passende Liste.

} {filtered.map((r) => ( {allowRecipe && ( - + )}
) } - if (kind === 'recipe') { - return ( - setKind(null)} - onSave={onCreateRecipe} - /> - ) - } return (

Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.

@@ -142,6 +131,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) { const [saving, setSaving] = useState(null) const [searchFor, setSearchFor] = useState(null) const [recipeFor, setRecipeFor] = useState(null) + const [recipeDraft, setRecipeDraft] = useState(null) const [createFor, setCreateFor] = useState(null) const [filter, setFilter] = useState('all') const [sinceDays, setSinceDays] = useState(28) @@ -273,7 +263,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) { await api.applyNutritionRecipe(rec.id, row.source_name_raw) setCreateFor(null) setSearchFor(null) - setNotice(`Rezept angelegt. Offene Zutaten erscheinen in der Liste.`) + setNotice('Liste angelegt. Offene Zutaten erscheinen unten zum Zuordnen.') await load() onMapped?.() } catch (e) { @@ -290,7 +280,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) { try { await api.applyNutritionRecipe(recipeId, sourceName) setRecipeFor(null) - setNotice('Rezept verknüpft. Offene Zutaten erscheinen in der Liste.') + setNotice('Liste verknüpft. Offene Zutaten erscheinen unten zum Zuordnen.') await load() onMapped?.() } catch (e) { @@ -371,7 +361,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) { { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} /> { load(); onChanged?.() }} /> { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} /> @@ -422,7 +412,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
{suggestQuery(u.source_name_raw) || u.source_name_raw}
- {u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`} + {u.kind === 'recipe_ingredient' ? 'Listenzutat' : `${u.count}×`} {u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''} {u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
@@ -455,24 +445,30 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( - + )} {u.kind !== 'recipe_ingredient' && recipes.length > 0 && ( - + )}
{createFor === key && ( createAndAssign(u, body)} - onCreateRecipe={(body) => createRecipeAndAssign(u, body)} + onWantRecipe={() => { + setCreateFor(null) + setSearchFor(null) + setRecipeDraft({ + row: u, + defaultName: suggestQuery(u.source_name_raw) || u.source_name_raw, + }) + }} /> )}
@@ -509,10 +505,31 @@ export default function NutritionFoodMap({ onChanged, onMapped }) { initialQuery={suggestQuery(searchFor.source_name_raw)} quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw} allowRecipe={searchFor.kind !== 'recipe_ingredient'} - learned={learned} onClose={() => setSearchFor(null)} onSelect={(food, extras) => assign(searchFor, food.id, extras, food)} - onCreateRecipe={searchFor.kind === 'recipe_ingredient' ? undefined : (body) => createRecipeAndAssign(searchFor, body)} + onWantRecipe={searchFor.kind === 'recipe_ingredient' ? undefined : () => { + const row = searchFor + setSearchFor(null) + setCreateFor(null) + setRecipeDraft({ + row, + defaultName: suggestQuery(row.source_name_raw) || row.source_name_raw, + }) + }} + /> + )} + {recipeDraft && ( + setRecipeDraft(null)} + onSave={async (body) => { + await createRecipeAndAssign(recipeDraft.row, body) + setRecipeDraft(null) + }} /> )} {recipeFor && ( diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 3fff4b7..0aed029 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -279,6 +279,7 @@ export const api = { putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)), deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}), resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})), + listFoodUnits: () => req('/bls/units'), searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}), suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })), createUserFood: (d) => req('/bls/foods/manual', json(d)),