feat: Listen-Dialog mit Mengen/Einheiten, Mitai ohne Kochrezepte
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s

Roh-Kombinationen statt Rezept-Sprache; Zutaten suchen, EL/TL/Prise auf Gramm. Gekochte Gerichte und Ausbeute bleiben Tandoor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-12 18:23:01 +02:00
parent a7e5511039
commit fa542e0fb9
17 changed files with 776 additions and 203 deletions

View File

@ -8,11 +8,27 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte
## Zuordnung (UX) ## 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,50,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. 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 ## Später
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge). 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.

View File

@ -11,13 +11,13 @@
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at` - `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute - `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
- `nutrition_day_marks``fasting` | `incomplete` - `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 ## Layer 1
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete - `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes` - `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) - `bls/recipe_parser.py``produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben)
## Import ## 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 - `/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` - `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 - `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping - 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). **Neu anlegen** fragt zuerst Lebensmittel vs. Rezept; Rezept-Zutaten ohne Mapping erscheinen als `kind=recipe_ingredient` in Unmapped. - 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) - `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) - 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**) - `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)

View File

@ -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. - **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. - **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-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“. - **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. - **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. - **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 - **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. - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.

View File

@ -168,6 +168,8 @@ def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]:
"source_name_normalized": ing.get("source_name_normalized"), "source_name_normalized": ing.get("source_name_normalized"),
"quantity_raw": ing.get("quantity_raw"), "quantity_raw": ing.get("quantity_raw"),
"quantity_g": _jsonable(ing.get("quantity_g")), "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, "sort_order": ing.get("sort_order") or 0,
} }
for ing in rec.get("ingredients") or [] for ing in rec.get("ingredients") or []

View File

@ -132,6 +132,16 @@ UNIT_ALIASES = {
MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0} 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} DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0}
COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"}) 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( QTY_PARSE_RE = re.compile(
r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$", r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$",
re.IGNORECASE, re.IGNORECASE,
@ -186,6 +196,74 @@ def detect_quantity_unit(*texts: str | None) -> str | None:
return 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( def get_food_mapping_with_cursor(
cur, cur,
source_name: str, source_name: str,

View File

@ -4,10 +4,32 @@ from __future__ import annotations
import uuid import uuid
from typing import Any 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 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]: def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
inserted = updated = ingredients = 0 inserted = updated = ingredients = 0
for rec in recipes: 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")) inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
if not inorm: if not inorm:
continue continue
raw_name = ing.get("source_name_raw") or inorm
qty = _resolved_ingredient_qty(cur, profile_id, raw_name, ing)
cur.execute( cur.execute(
""" """
INSERT INTO food_recipe_ingredients INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized, (id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, sort_order) quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", """,
( (
str(uuid.uuid4()), rid, ing["source_name_raw"], inorm, str(uuid.uuid4()), rid, raw_name, inorm,
ing.get("quantity_raw"), ing.get("quantity_g"), ing.get("sort_order") or 0, qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"],
ing.get("sort_order") or 0,
), ),
) )
ingredients += 1 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] ids = [str(r["id"]) for r in recipes]
cur.execute( 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 FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s::uuid[]) WHERE recipe_id = ANY(%s::uuid[])
ORDER BY sort_order, source_name_raw 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) inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
if not inorm: if not inorm:
continue continue
qty = ing.get("quantity_g") qty = _resolved_ingredient_qty(cur, profile_id, raw or inorm, ing)
try:
qty = float(qty) if qty not in (None, "") else None
except (TypeError, ValueError):
qty = None
cur.execute( cur.execute(
""" """
INSERT INTO food_recipe_ingredients INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized, (id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, sort_order) quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s) 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) link_recipes_to_items(cur, profile_id)
saved = get_recipe(cur, profile_id, rid) saved = get_recipe(cur, profile_id, rid)
@ -249,7 +274,7 @@ def mapped_ingredient_quantities(
return None return None
cur.execute( cur.execute(
""" """
SELECT source_name_raw, quantity_g SELECT source_name_raw, quantity_g, quantity_raw, quantity_amount, source_unit
FROM food_recipe_ingredients FROM food_recipe_ingredients
WHERE recipe_id = %s WHERE recipe_id = %s
ORDER BY sort_order ORDER BY sort_order
@ -259,22 +284,29 @@ def mapped_ingredient_quantities(
ings = cur.fetchall() ings = cur.fetchall()
if not ings: if not ings:
return None 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: if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
scale = float(eaten_qty_g) / total_g scale = float(eaten_qty_g) / total_g
else: else:
portions = float(rec.get("portions") or 1) or 1.0 portions = float(rec.get("portions") or 1) or 1.0
scale = 1.0 / portions scale = 1.0 / portions
out = [] return [{**i, "quantity_g": i["quantity_g"] * scale} for i in resolved_ings]
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
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None: def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:

View File

@ -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 $$;

View File

@ -13,6 +13,7 @@ from data_layer.food_mapping import (
apply_mapping_to_items, apply_mapping_to_items,
apply_quantities_to_items, apply_quantities_to_items,
clear_mapping_from_items, clear_mapping_from_items,
list_quantity_units,
normalize_food_name, normalize_food_name,
upsert_food_mapping, upsert_food_mapping,
) )
@ -79,6 +80,11 @@ def _schedule_rebuild(profile_id: str, dates: list[str], context: str) -> None:
).start() ).start()
@router.get("/units")
def list_food_units(session: dict = Depends(require_auth)):
return list_quantity_units()
@router.get("/foods") @router.get("/foods")
def search_foods( def search_foods(
q: str = "", q: str = "",

View File

@ -28,6 +28,9 @@ class RecipeIngredientIn(BaseModel):
source_name_raw: str source_name_raw: str
quantity_raw: Optional[str] = None quantity_raw: Optional[str] = None
quantity_g: Optional[float] = 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): class RecipeIn(BaseModel):

View File

@ -3,11 +3,13 @@ from datetime import date
from csv_parser.executor import guess_nutrition_item_fields from csv_parser.executor import guess_nutrition_item_fields
from data_layer.food_mapping import ( from data_layer.food_mapping import (
filter_unmapped_since, filter_unmapped_since,
list_quantity_units,
merge_unmapped_rows, merge_unmapped_rows,
normalize_food_name, normalize_food_name,
parse_quantity, parse_quantity,
parse_quantity_g, parse_quantity_g,
primary_search_query, primary_search_query,
resolve_quantity,
) )
from data_layer.nutrition_items import macros_differ from data_layer.nutrition_items import macros_differ
@ -46,6 +48,20 @@ def test_parse_quantity_g():
assert parse_quantity("2 EL")["unit"] == "el" 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(): def test_guess_fddb_bezeichnung_without_template_mapping():
name, qty = guess_nutrition_item_fields({ name, qty = guess_nutrition_item_fields({
"bezeichnung": "50 g Hähnchen", "bezeichnung": "50 g Hähnchen",

View File

@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
APP_VERSION = "0.9v" APP_VERSION = "0.9v"
BUILD_DATE = "2026-09-12" BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260912b" # 065 omega attribute extensions DB_SCHEMA_VERSION = "20260912c" # 066 recipe ingredient units
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.0", "auth": "1.2.0",
@ -20,8 +20,8 @@ MODULE_VERSIONS = {
"circumference": "1.0.1", "circumference": "1.0.1",
"caliper": "1.0.1", "caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.2.9", # Zuordnen: Lebensmittel oder Rezept anlegen "nutrition": "1.3.2", # UI: Liste/Kombination; Tandoor-Gerichte später
"bls": "1.0.4", "bls": "1.0.5", # GET /bls/units
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
"prompts": "1.1.0", "prompts": "1.1.0",
@ -55,6 +55,9 @@ CHANGELOG = [
"Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%", "Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%",
"Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g", "Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g",
"Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern", "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",
], ],
}, },
{ {

View File

@ -15,7 +15,10 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
- Fasten-/Lücken-Marken unabhängig vom Import - Fasten-/Lücken-Marken unabhängig vom Import
- Einzelerfassung nur Makros unverändert - Einzelerfassung nur Makros unverändert
- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer) - 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 - Zuordnungen und Listen als JSON zwischen Instanzen übertragen
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping - Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung - 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

View File

@ -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 (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 21000,
padding: 16,
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="recipe-dialog-title"
style={{
width: '100%', maxWidth: 560, maxHeight: 'min(92vh, 800px)',
background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<h2 id="recipe-dialog-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Liste'}</h2>
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onClose}>Schließen</button>
</div>
<div style={{ overflowY: 'auto', padding: '12px 16px 16px', flex: 1 }}>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.5, margin: '0 0 12px' }}>
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.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
<label className="form-label">Name der Liste</label>
<input
ref={nameRef}
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 12 }}
placeholder="z. B. Overnight Oats"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<label className="form-label">Portionen</label>
<input
className="form-input"
type="number"
min="0.1"
step="0.1"
style={{ width: 96, textAlign: 'left', marginBottom: 16 }}
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
<p style={{ fontSize: 13, fontWeight: 600, margin: '0 0 8px' }}>Zutat suchen</p>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Name, z. B. Haferflocken oder Joghurt 10%"
value={q}
onChange={(e) => onSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addFreeText() } }}
/>
{showResults && (
<div style={{ marginTop: 8, border: '1px solid var(--border)', borderRadius: 10, background: 'var(--surface2)' }}>
{mappedHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Bereits zugeordnet</div>
)}
{mappedHits.map((m) => (
<button
key={m.id || m.source_name_raw}
type="button"
className="btn btn-secondary btn-full"
style={{ margin: '0 8px 8px', justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => addMapped(m)}
>
<span>
<strong style={{ display: 'block' }}>{m.source_name_raw}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}> {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}</span>
</span>
</button>
))}
{searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog</p>}
{catalogOnly.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Katalog</div>
)}
{catalogOnly.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ margin: '0 8px 8px', justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => addCatalog(h)}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
</span>
</span>
</button>
))}
<button type="button" className="btn btn-secondary btn-full" style={{ margin: '0 8px 8px' }} onClick={addFreeText}>
{q.trim()} als offene Zutat übernehmen
</button>
</div>
)}
<p style={{ fontSize: 13, fontWeight: 600, margin: '16px 0 8px' }}>Zutaten ({ings.length})</p>
{ings.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>Noch keine Zutaten oben suchen und übernehmen.</p>
)}
{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 (
<div key={`${ing.source_name_raw}-${i}`} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
className="form-input"
style={{ textAlign: 'left', flex: 1 }}
value={ing.source_name_raw}
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value, foodId: null, mappedLabel: '' } : x))}
/>
<button type="button" className="btn btn-secondary" style={{ padding: '6px 10px' }} onClick={() => setIngs((list) => list.filter((_, j) => j !== i))} aria-label="Zutat entfernen">×</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
<input
ref={(el) => { 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))}
/>
<select
className="form-input"
style={{ textAlign: 'left' }}
value={ing.unit}
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, unit: e.target.value } : x))}
>
{units.map((u) => (
<option key={u.id} value={u.id}>{u.label}</option>
))}
</select>
</div>
{!isMass && (
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 6 }}>
1 {meta?.label || ing.unit} ={' '}
<input
className="form-input"
type="number"
min="0.01"
step="0.1"
style={{ width: 80, display: 'inline-block', textAlign: 'left' }}
placeholder={meta?.default_g != null ? String(meta.default_g) : 'g'}
value={ing.gramsPerUnit}
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, gramsPerUnit: e.target.value } : x))}
/>
{' '}g
</label>
)}
<div style={{ fontSize: 11, color: mapped ? 'var(--accent-dark)' : 'var(--text3)', marginTop: 4 }}>
{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'}
</div>
</div>
)
})}
</div>
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-secondary" disabled={saving || disabled} onClick={onClose}>Abbrechen</button>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={saving || disabled} onClick={save}>
{saving ? 'Speichere…' : (submitLabel || 'Liste speichern')}
</button>
</div>
</div>
</div>
)
}

View File

@ -1,118 +1,20 @@
import { useState } from 'react' import { useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import FoodRecipeDialog from './FoodRecipeDialog'
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 (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>
Zutaten, die schon zugeordnet sind, werden übernommen. Offene erscheinen danach in der Liste zum Zuordnen.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} placeholder="Rezeptname" value={name} onChange={(e) => setName(e.target.value)} />
<label style={{ fontSize: 12, color: 'var(--text2)' }}>
Portionen
<input className="form-input" type="number" min="0.1" step="0.1" style={{ width: 80, textAlign: 'left', marginLeft: 8 }} value={portions} onChange={(e) => setPortions(e.target.value)} />
</label>
<p style={{ fontSize: 12, margin: '10px 0 6px', color: 'var(--text2)' }}>Zutaten (Name + Gramm)</p>
{ings.map((ing, i) => {
const mapped = mappingHint(ing.source_name_raw, learned)
return (
<div key={i} style={{ marginBottom: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px', gap: 6 }}>
<input className="form-input" style={{ textAlign: 'left' }} placeholder="z. B. Haferflocken" value={ing.source_name_raw} onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value } : x))} />
<input className="form-input" type="number" min="0" step="0.1" style={{ textAlign: 'left' }} placeholder="g" value={ing.quantity_g} onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, quantity_g: e.target.value } : x))} />
</div>
{ing.source_name_raw.trim() && (
<div style={{ fontSize: 11, color: mapped ? 'var(--accent-dark)' : 'var(--text3)', marginTop: 2 }}>
{mapped ? `zugeordnet: ${mapped.food_name_de || mapped.source_name_raw}` : 'noch offen — nach dem Speichern in der Liste zuordnen'}
</div>
)}
</div>
)
})}
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setIngs((list) => [...list, emptyIng()])}>Zutat hinzufügen</button>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
{onCancel && (
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onCancel}>Zurück</button>
)}
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={disabled || saving} onClick={save}>
{saving ? 'Speichere…' : (submitLabel || 'Rezept speichern')}
</button>
</div>
</div>
)
}
export default function FoodRecipeEditor({ recipes, learned, onChanged }) { export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [draft, setDraft] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const startNew = () => { const startNew = () => {
setEditing('new') setEditing('new')
setDraft({ name: '', portions: '1' })
setError(null) setError(null)
setOpen(true) setOpen(true)
} }
const startEdit = (r) => { const startEdit = (r) => {
setEditing(r) setEditing(r)
setDraft(r)
setError(null) setError(null)
setOpen(true) setOpen(true)
} }
@ -121,12 +23,11 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
if (editing === 'new') await api.createNutritionRecipe(body) if (editing === 'new') await api.createNutritionRecipe(body)
else await api.updateNutritionRecipe(editing.id, body) else await api.updateNutritionRecipe(editing.id, body)
setEditing(null) setEditing(null)
setDraft(null)
onChanged?.() onChanged?.()
} }
const remove = async (id) => { const remove = async (id) => {
if (!confirm('Rezept wirklich löschen?')) return if (!confirm('Liste wirklich löschen?')) return
try { try {
await api.deleteNutritionRecipe(id) await api.deleteNutritionRecipe(id)
if (editing && editing !== 'new' && editing.id === id) setEditing(null) if (editing && editing !== 'new' && editing.id === id) setEditing(null)
@ -139,7 +40,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
return ( return (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<h3 style={{ fontSize: 14, margin: 0 }}>Eigene Rezepte / Listen ({recipes.length})</h3> <h3 style={{ fontSize: 14, margin: 0 }}>Eigene Listen / Kombinationen ({recipes.length})</h3>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => { setOpen((v) => !v); if (open) setEditing(null) }}> <button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => { setOpen((v) => !v); if (open) setEditing(null) }}>
{open ? 'Einklappen' : 'Anzeigen'} {open ? 'Einklappen' : 'Anzeigen'}
</button> </button>
@ -147,7 +48,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
{open && ( {open && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>} {error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
<button type="button" className="btn btn-secondary btn-full" onClick={startNew}>Rezept anlegen</button> <button type="button" className="btn btn-secondary btn-full" onClick={startNew}>Liste anlegen</button>
{recipes.map((r) => ( {recipes.map((r) => (
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}> <div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
<div style={{ fontWeight: 600 }}>{r.name_raw}</div> <div style={{ fontWeight: 600 }}>{r.name_raw}</div>
@ -161,22 +62,29 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
</div> </div>
</div> </div>
))} ))}
{editing === 'new' && ( </div>
<RecipeForm learned={learned} submitLabel="Rezept anlegen" onSave={save} onCancel={() => setEditing(null)} />
)} )}
{editing && editing !== 'new' && ( {editing === 'new' && (
<RecipeForm <FoodRecipeDialog
key={editing.id} title="Liste anlegen"
defaultName={draft?.name_raw}
defaultPortions={draft?.portions}
defaultIngredients={draft?.ingredients}
learned={learned} learned={learned}
submitLabel="Rezept speichern" submitLabel="Liste anlegen"
onSave={save} onSave={save}
onCancel={() => setEditing(null)} onClose={() => setEditing(null)}
/> />
)} )}
</div> {editing && editing !== 'new' && (
<FoodRecipeDialog
key={editing.id}
title="Liste bearbeiten"
defaultName={editing.name_raw}
defaultPortions={editing.portions}
defaultIngredients={editing.ingredients}
learned={learned}
submitLabel="Liste speichern"
onSave={save}
onClose={() => setEditing(null)}
/>
)} )}
</div> </div>
) )

View File

@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import { RecipeForm } from './FoodRecipeEditor'
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse']) const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
const UNIT_LABEL = { const UNIT_LABEL = {
@ -26,7 +25,7 @@ function detectUnit(...texts) {
return null 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 [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([]) const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -210,30 +209,21 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
{!showCreate ? ( {!showCreate ? (
<button type="button" className="btn btn-secondary btn-full" onClick={() => { <button type="button" className="btn btn-secondary btn-full" onClick={() => {
setShowCreate(true) setShowCreate(true)
setCreateKind(allowRecipe && onCreateRecipe ? null : 'food') setCreateKind(allowRecipe && onWantRecipe ? null : 'food')
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' })) setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
}}> }}>
{allowRecipe && onCreateRecipe ? 'Lebensmittel oder Rezept anlegen' : 'Eigenes Lebensmittel anlegen'} {allowRecipe && onWantRecipe ? 'Lebensmittel oder Liste anlegen' : 'Eigenes Lebensmittel anlegen'}
</button> </button>
) : !createKind ? ( ) : !createKind ? (
<div> <div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Was möchtest du anlegen?</p> <p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Was möchtest du anlegen?</p>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 8px' }}>Lebensmittel = ein Katalogeintrag. Rezept = mehrere Zutaten; offene Zutaten erscheinen danach in der Liste.</p> <p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 8px' }}>Lebensmittel = ein Katalogeintrag. Liste = Roh-Kombination (z. B. Müsli) mit Zutatensuche.</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreateKind('food')}>Lebensmittel</button> <button type="button" className="btn btn-primary" onClick={() => setCreateKind('food')}>Lebensmittel</button>
<button type="button" className="btn btn-secondary" onClick={() => setCreateKind('recipe')}>Rezept</button> <button type="button" className="btn btn-secondary" onClick={onWantRecipe}>Liste</button>
<button type="button" className="btn btn-secondary" onClick={() => { setShowCreate(false); setCreateKind(null) }}>Zurück</button> <button type="button" className="btn btn-secondary" onClick={() => { setShowCreate(false); setCreateKind(null) }}>Zurück</button>
</div> </div>
</div> </div>
) : createKind === 'recipe' ? (
<RecipeForm
defaultName={manual.name_de || q || initialQuery || ''}
learned={learned}
disabled={creating}
submitLabel="Rezept anlegen und zuordnen"
onCancel={() => setCreateKind(null)}
onSave={onCreateRecipe}
/>
) : ( ) : (
<div> <div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag</p> <p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag</p>
@ -253,7 +243,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
setServingG={setServingG} setServingG={setServingG}
/> />
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}> <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
{allowRecipe && onCreateRecipe && ( {allowRecipe && onWantRecipe && (
<button type="button" className="btn btn-secondary" disabled={creating} onClick={() => setCreateKind(null)}>Zurück</button> <button type="button" className="btn btn-secondary" disabled={creating} onClick={() => setCreateKind(null)}>Zurück</button>
)} )}
<button <button

View File

@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal' import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeEditor, { RecipeForm } from './FoodRecipeEditor' import FoodRecipeEditor from './FoodRecipeEditor'
import FoodRecipeDialog from './FoodRecipeDialog'
const PERIODS = [ const PERIODS = [
{ days: 14, label: '14 Tage' }, { days: 14, label: '14 Tage' },
@ -52,14 +53,14 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Eigenes Rezept wählen</h2> <h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Eigene Liste wählen</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button> <button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div> </div>
<div style={{ padding: '12px 16px' }}> <div style={{ padding: '12px 16px' }}>
<input className="form-input" style={{ width: '100%', textAlign: 'left' }} autoFocus placeholder="Rezeptname filtern" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-input" style={{ width: '100%', textAlign: 'left' }} autoFocus placeholder="Listenname filtern" value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}> <div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept.</p>} {filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Keine passende Liste.</p>}
{filtered.map((r) => ( {filtered.map((r) => (
<button key={r.id} type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} onClick={() => onPick(r.id)}> <button key={r.id} type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} onClick={() => onPick(r.id)}>
<span> <span>
@ -74,7 +75,7 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
) )
} }
function InlineCreate({ defaultName, disabled, learned, allowRecipe, onCreateFood, onCreateRecipe }) { function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWantRecipe }) {
const [kind, setKind] = useState(allowRecipe ? null : 'food') const [kind, setKind] = useState(allowRecipe ? null : 'food')
const [name, setName] = useState(defaultName || '') const [name, setName] = useState(defaultName || '')
const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' }) const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
@ -83,28 +84,16 @@ function InlineCreate({ defaultName, disabled, learned, allowRecipe, onCreateFoo
if (!kind) { if (!kind) {
return ( return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}> <div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Lebensmittel (ein Eintrag im Katalog) oder Rezept (mehrere Zutaten)?</p> <p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Lebensmittel (ein Eintrag im Katalog) oder Liste (Roh-Kombination, z. B. Müsli)?</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={disabled} onClick={() => setKind('food')}>Lebensmittel</button> <button type="button" className="btn btn-primary" disabled={disabled} onClick={() => setKind('food')}>Lebensmittel</button>
{allowRecipe && ( {allowRecipe && (
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={() => setKind('recipe')}>Rezept</button> <button type="button" className="btn btn-secondary" disabled={disabled} onClick={onWantRecipe}>Liste</button>
)} )}
</div> </div>
</div> </div>
) )
} }
if (kind === 'recipe') {
return (
<RecipeForm
defaultName={defaultName}
learned={learned}
disabled={disabled}
submitLabel="Rezept anlegen und zuordnen"
onCancel={() => setKind(null)}
onSave={onCreateRecipe}
/>
)
}
return ( return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}> <div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.</p> <p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.</p>
@ -142,6 +131,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
const [saving, setSaving] = useState(null) const [saving, setSaving] = useState(null)
const [searchFor, setSearchFor] = useState(null) const [searchFor, setSearchFor] = useState(null)
const [recipeFor, setRecipeFor] = useState(null) const [recipeFor, setRecipeFor] = useState(null)
const [recipeDraft, setRecipeDraft] = useState(null)
const [createFor, setCreateFor] = useState(null) const [createFor, setCreateFor] = useState(null)
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [sinceDays, setSinceDays] = useState(28) const [sinceDays, setSinceDays] = useState(28)
@ -273,7 +263,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
await api.applyNutritionRecipe(rec.id, row.source_name_raw) await api.applyNutritionRecipe(rec.id, row.source_name_raw)
setCreateFor(null) setCreateFor(null)
setSearchFor(null) setSearchFor(null)
setNotice(`Rezept angelegt. Offene Zutaten erscheinen in der Liste.`) setNotice('Liste angelegt. Offene Zutaten erscheinen unten zum Zuordnen.')
await load() await load()
onMapped?.() onMapped?.()
} catch (e) { } catch (e) {
@ -290,7 +280,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
try { try {
await api.applyNutritionRecipe(recipeId, sourceName) await api.applyNutritionRecipe(recipeId, sourceName)
setRecipeFor(null) setRecipeFor(null)
setNotice('Rezept verknüpft. Offene Zutaten erscheinen in der Liste.') setNotice('Liste verknüpft. Offene Zutaten erscheinen unten zum Zuordnen.')
await load() await load()
onMapped?.() onMapped?.()
} catch (e) { } catch (e) {
@ -371,7 +361,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
<input ref={listRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} /> <input ref={listRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} />
<button type="button" className="btn btn-secondary btn-full" disabled={importing} onClick={() => listRef.current?.click()}> <button type="button" className="btn btn-secondary btn-full" disabled={importing} onClick={() => listRef.current?.click()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'} {importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
</button> </button>
<FoodRecipeEditor recipes={recipes} learned={learned} onChanged={() => { load(); onChanged?.() }} /> <FoodRecipeEditor recipes={recipes} learned={learned} onChanged={() => { load(); onChanged?.() }} />
<input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} /> <input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
@ -422,7 +412,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
<div> <div>
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div> <div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}> <div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`} {u.kind === 'recipe_ingredient' ? 'Listenzutat' : `${u.count}×`}
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''} {u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''}
{u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''} {u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
</div> </div>
@ -455,24 +445,30 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}> <button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}>
{createFor === key ? 'Anlegen schließen' : (u.kind === 'recipe_ingredient' ? 'Neu anlegen' : 'Lebensmittel oder Rezept')} {createFor === key ? 'Anlegen schließen' : (u.kind === 'recipe_ingredient' ? 'Neu anlegen' : 'Lebensmittel oder Liste')}
</button> </button>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button> <button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Rezept</button> <button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Liste</button>
)} )}
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && ( {u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Rezept wählen</button> <button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Liste wählen</button>
)} )}
</div> </div>
{createFor === key && ( {createFor === key && (
<InlineCreate <InlineCreate
defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw} defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw}
disabled={busyRow} disabled={busyRow}
learned={learned}
allowRecipe={u.kind !== 'recipe_ingredient'} allowRecipe={u.kind !== 'recipe_ingredient'}
onCreateFood={(body) => createAndAssign(u, body)} onCreateFood={(body) => 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,
})
}}
/> />
)} )}
</div> </div>
@ -509,10 +505,31 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
initialQuery={suggestQuery(searchFor.source_name_raw)} initialQuery={suggestQuery(searchFor.source_name_raw)}
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw} quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
allowRecipe={searchFor.kind !== 'recipe_ingredient'} allowRecipe={searchFor.kind !== 'recipe_ingredient'}
learned={learned}
onClose={() => setSearchFor(null)} onClose={() => setSearchFor(null)}
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)} 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 && (
<FoodRecipeDialog
title="Liste anlegen"
defaultName={recipeDraft.defaultName}
learned={learned}
disabled={saving === recipeDraft.row.source_name_raw}
submitLabel="Liste anlegen und zuordnen"
onClose={() => setRecipeDraft(null)}
onSave={async (body) => {
await createRecipeAndAssign(recipeDraft.row, body)
setRecipeDraft(null)
}}
/> />
)} )}
{recipeFor && ( {recipeFor && (

View File

@ -279,6 +279,7 @@ export const api = {
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)), putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}), deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})), 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 } : {}), 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 })), suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
createUserFood: (d) => req('/bls/foods/manual', json(d)), createUserFood: (d) => req('/bls/foods/manual', json(d)),