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)
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.

View File

@ -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**)

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.
- **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.

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"),
"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 []

View File

@ -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,

View File

@ -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:

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_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 = "",

View File

@ -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):

View File

@ -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",

View File

@ -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",
],
},
{

View File

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

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 { 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 (
<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>
)
}
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 (
<div style={{ marginTop: 16 }}>
<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) }}>
{open ? 'Einklappen' : 'Anzeigen'}
</button>
@ -147,7 +48,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
{open && (
<div style={{ marginTop: 8 }}>
{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) => (
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
@ -161,23 +62,30 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
</div>
</div>
))}
{editing === 'new' && (
<RecipeForm learned={learned} submitLabel="Rezept anlegen" onSave={save} onCancel={() => setEditing(null)} />
)}
{editing && editing !== 'new' && (
<RecipeForm
key={editing.id}
defaultName={draft?.name_raw}
defaultPortions={draft?.portions}
defaultIngredients={draft?.ingredients}
learned={learned}
submitLabel="Rezept speichern"
onSave={save}
onCancel={() => setEditing(null)}
/>
)}
</div>
)}
{editing === 'new' && (
<FoodRecipeDialog
title="Liste anlegen"
learned={learned}
submitLabel="Liste anlegen"
onSave={save}
onClose={() => setEditing(null)}
/>
)}
{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>
)
}

View File

@ -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 ? (
<button type="button" className="btn btn-secondary btn-full" onClick={() => {
setShowCreate(true)
setCreateKind(allowRecipe && onCreateRecipe ? null : 'food')
setCreateKind(allowRecipe && onWantRecipe ? null : 'food')
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>
) : !createKind ? (
<div>
<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 }}>
<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>
</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>
<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}
/>
<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

View File

@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeEditor, { RecipeForm } from './FoodRecipeEditor'
import FoodRecipeEditor from './FoodRecipeEditor'
import FoodRecipeDialog from './FoodRecipeDialog'
const PERIODS = [
{ 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)' }}>
<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>
</div>
<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 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) => (
<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>
@ -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 [name, setName] = useState(defaultName || '')
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) {
return (
<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 }}>
<button type="button" className="btn btn-primary" disabled={disabled} onClick={() => setKind('food')}>Lebensmittel</button>
{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>
)
}
if (kind === 'recipe') {
return (
<RecipeForm
defaultName={defaultName}
learned={learned}
disabled={disabled}
submitLabel="Rezept anlegen und zuordnen"
onCancel={() => setKind(null)}
onSave={onCreateRecipe}
/>
)
}
return (
<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>
@ -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 }) {
<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()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
</button>
<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) }} />
@ -422,7 +412,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
<div>
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
<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.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
</div>
@ -455,24 +445,30 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
<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)}>
{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 type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
{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 && (
<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>
{createFor === key && (
<InlineCreate
defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw}
disabled={busyRow}
learned={learned}
allowRecipe={u.kind !== 'recipe_ingredient'}
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>
@ -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 && (
<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 && (

View File

@ -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)),