From a7e55110395415c9fd1f08fd43123f2020d21fbb Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 12 Sep 2026 16:59:24 +0200 Subject: [PATCH] feat: Rezepte beim Zuordnen anlegen und offene Zutaten listen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lebensmittel oder Rezept wählbar; gemappte Zutaten werden übernommen, offene erscheinen in der Liste. Dazu Fettgehalt-Suche, EPA-Stoffe, Rezept-CRUD und Wechsel bestehender Zuordnungen. Co-authored-by: Cursor --- .claude/docs/functional/BLS_FOOD_REFERENCE.md | 8 +- .claude/docs/technical/BLS_FOOD_REFERENCE.md | 8 +- CLAUDE.md | 3 + backend/data_layer/food_attributes.py | 128 ++++++++++++ backend/data_layer/food_knowledge.py | 24 ++- backend/data_layer/food_mapping.py | 50 ++++- backend/data_layer/food_recipes.py | 105 +++++++++- backend/data_layer/food_suggest.py | 127 ++++++++++-- .../065_food_attribute_omega_extensions.sql | 13 ++ backend/routers/admin_bls.py | 20 +- backend/routers/admin_food_mappings.py | 52 ++++- backend/routers/bls.py | 19 +- backend/routers/nutrition.py | 73 ++++++- backend/tests/test_food_attributes.py | 19 ++ backend/tests/test_food_knowledge.py | 15 ++ backend/tests/test_food_mapping.py | 6 + backend/tests/test_food_recipes.py | 10 + backend/tests/test_food_suggest.py | 44 ++++- backend/version.py | 9 +- docs/issues/issue-bls-food-mapping.md | 1 + .../src/components/FoodNutrientFields.jsx | 142 ++++++++++++++ frontend/src/components/FoodRecipeEditor.jsx | 183 ++++++++++++++++++ frontend/src/components/FoodSearchModal.jsx | 98 ++++++---- frontend/src/components/NutritionFoodMap.jsx | 120 ++++++++---- frontend/src/pages/AdminFoodMappingsPage.jsx | 95 ++++++++- frontend/src/utils/api.js | 9 +- 26 files changed, 1234 insertions(+), 147 deletions(-) create mode 100644 backend/data_layer/food_attributes.py create mode 100644 backend/migrations/065_food_attribute_omega_extensions.sql create mode 100644 backend/tests/test_food_attributes.py create mode 100644 backend/tests/test_food_recipes.py create mode 100644 frontend/src/components/FoodNutrientFields.jsx create mode 100644 frontend/src/components/FoodRecipeEditor.jsx diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 981d4ad..df89171 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -8,7 +8,7 @@ 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 (z. B. Salz), **Neu anlegen** in derselben Zeile. Zusätzlich Popup-Suche. 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 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. ## FDDB-Listen / eigene Rezepte @@ -26,6 +26,12 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a - Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import. - Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten. +## Manuelle Lebensmittel / Supplemente + +Eigene Einträge (z. B. Norsan Omega-3 + EPA) speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Admin → Stoffe & Attribute. + +Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle. + ## Später Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge). diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index cdf3bb3..acc3d42 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -28,13 +28,15 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / ## Router -- `/api/bls/*` — Suche, eigene Foods, eigene Mappings +- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings +- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat - `/api/admin/bls/*` — Import, Katalog, Attribute - `/api/admin/food-mappings` — Admin-CRUD - `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve -- `GET /api/nutrition/recipes`, `POST /api/nutrition/recipes/import-fddb-lists`, `POST /api/nutrition/recipes/{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 - 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), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce) +- 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. - `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80) - Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar) - `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**) diff --git a/CLAUDE.md b/CLAUDE.md index 9614160..f6a29a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,9 @@ frontend/src/ - **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; 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. - **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. diff --git a/backend/data_layer/food_attributes.py b/backend/data_layer/food_attributes.py new file mode 100644 index 0000000..980ce00 --- /dev/null +++ b/backend/data_layer/food_attributes.py @@ -0,0 +1,128 @@ +"""Catalog attribute definitions and numeric values (BLS EAV + extensions).""" +from __future__ import annotations + +from typing import Any + +MACRO_TO_KEY = { + "kcal": "ENERCC", + "protein_g": "PROT625", + "fat_g": "FAT", + "carbs_g": "CHO", +} + + +def scale_to_per_100g(value: float, serving_g: float | None) -> float: + if not serving_g or serving_g <= 0 or serving_g == 100: + return float(value) + return float(value) * (100.0 / float(serving_g)) + + +def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]: + q = (query or "").strip() + lim = min(max(int(limit or 40), 1), 80) + if q: + like = f"%{q}%" + cur.execute( + """ + SELECT id, attr_key, name_de, name_en, unit, category, origin + FROM food_attributes + WHERE is_active = true AND data_type = 'num_per_100g' + AND ( + attr_key ILIKE %s OR name_de ILIKE %s + OR COALESCE(name_en, '') ILIKE %s + ) + ORDER BY + CASE WHEN attr_key ILIKE %s THEN 0 + WHEN name_de ILIKE %s THEN 1 + ELSE 2 END, + sort_order, attr_key + LIMIT %s + """, + (like, like, like, q, f"{q}%", lim), + ) + else: + cur.execute( + """ + SELECT id, attr_key, name_de, name_en, unit, category, origin + FROM food_attributes + WHERE is_active = true AND data_type = 'num_per_100g' + ORDER BY sort_order, attr_key + LIMIT %s + """, + (lim,), + ) + return [dict(r) for r in cur.fetchall()] + + +def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int: + if not values: + return 0 + written = 0 + for raw_key, raw_val in values.items(): + key = str(raw_key or "").strip() + if not key or raw_val is None or raw_val == "": + continue + try: + num = float(raw_val) + except (TypeError, ValueError): + continue + cur.execute( + """ + SELECT id FROM food_attributes + WHERE attr_key = %s AND is_active = true AND data_type = 'num_per_100g' + """, + (key,), + ) + row = cur.fetchone() + if not row: + continue + cur.execute( + """ + INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace) + VALUES (%s, %s, %s, false) + ON CONFLICT (food_id, attribute_id) + DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW() + """, + (food_id, row["id"], num), + ) + written += 1 + return written + + +def macros_and_attributes_to_values( + macros: dict[str, Any] | None, + attributes: dict[str, Any] | None, + serving_g: float | None = None, +) -> dict[str, float]: + out: dict[str, float] = {} + for field, key in MACRO_TO_KEY.items(): + if not macros or field not in macros or macros[field] is None or macros[field] == "": + continue + try: + out[key] = scale_to_per_100g(float(macros[field]), serving_g) + except (TypeError, ValueError): + continue + for key, raw in (attributes or {}).items(): + if raw is None or raw == "": + continue + try: + out[str(key)] = scale_to_per_100g(float(raw), serving_g) + except (TypeError, ValueError): + continue + return out + + +def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]: + cur.execute( + """ + SELECT a.attr_key, v.value_num + FROM food_attribute_values v + JOIN food_attributes a ON a.id = v.attribute_id + WHERE v.food_id = %s AND a.data_type = 'num_per_100g' + AND v.value_num IS NOT NULL AND v.is_trace = false + AND NOT (a.attr_key = ANY(%s)) + ORDER BY a.sort_order, a.attr_key + """, + (food_id, list(MACRO_TO_KEY.values())), + ) + return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()} diff --git a/backend/data_layer/food_knowledge.py b/backend/data_layer/food_knowledge.py index 3f6ab66..0abbbbe 100644 --- a/backend/data_layer/food_knowledge.py +++ b/backend/data_layer/food_knowledge.py @@ -22,13 +22,19 @@ BUNDLE_VERSION = 1 def _jsonable(value: Any) -> Any: if value is None: return None + if isinstance(value, (str, int, float, bool)): + return value if isinstance(value, Decimal): return float(value) if isinstance(value, UUID): return str(value) + if isinstance(value, dict): + return {str(k): _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] if hasattr(value, "isoformat"): return value.isoformat() - return value + return str(value) def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]: @@ -54,7 +60,7 @@ def portable_mapping(row: dict[str, Any]) -> dict[str, Any]: "food_name_de": row.get("food_name_de") or row.get("name_de"), "catalog_kind": row.get("catalog_kind"), "external_key": row.get("external_key"), - "grams_per_unit": row.get("grams_per_unit"), + "grams_per_unit": _jsonable(row.get("grams_per_unit")), "source_unit": row.get("source_unit"), } @@ -126,12 +132,14 @@ def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]: manuals = [] for food in cur.fetchall(): macros = catalog_macros_for_item(cur, food["id"], 100.0) + from data_layer.food_attributes import extra_attributes_for_food manuals.append({ "name_de": food["name_de"], "name_en": food.get("name_en"), "catalog_kind": food["catalog_kind"], "external_key": food.get("external_key"), "macros_per_100g": macros, + "attributes": extra_attributes_for_food(cur, food["id"]), }) cur.execute( """ @@ -165,14 +173,14 @@ def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]: for ing in rec.get("ingredients") or [] ], }) - return { + return _jsonable({ "format": BUNDLE_FORMAT, "version": BUNDLE_VERSION, "exported_at": datetime.now(timezone.utc).isoformat(), "manual_foods": manuals, "mappings": mappings, "recipes": recipes, - } + }) def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None: @@ -180,9 +188,10 @@ def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | Non if not name: return None existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"}) + from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes + values = macros_and_attributes_to_values(food.get("macros_per_100g"), food.get("attributes")) if existing: - from routers.admin_bls import _write_manual_macros - _write_manual_macros(cur, existing, food.get("macros_per_100g")) + write_numeric_attributes(cur, existing, values) return existing key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}" cur.execute( @@ -195,8 +204,7 @@ def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | Non (name, food.get("name_en"), profile_id, key), ) food_id = str(cur.fetchone()["id"]) - from routers.admin_bls import _write_manual_macros - _write_manual_macros(cur, food_id, food.get("macros_per_100g")) + write_numeric_attributes(cur, food_id, values) return food_id diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index 462b52c..f206ba5 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -31,6 +31,17 @@ def normalize_food_name(raw: str | None) -> str: return s.lower() +_LIST_COMMA_RE = re.compile(r",(?!\s*\d)") + + +def primary_search_query(query: str | None) -> str: + """Use the name before a list-comma, but keep decimal commas (9,5 %).""" + q = (query or "").strip() + if not q: + return "" + return _LIST_COMMA_RE.split(q, maxsplit=1)[0].strip() + + def merge_unmapped_rows(rows: list[dict]) -> list[dict]: merged: dict[str, dict] = {} for row in rows: @@ -359,17 +370,50 @@ def _value_origin_for_food(cur, food_id: str) -> str: return "manual_catalog" +_SEARCH_NUM_RE = re.compile(r"\d+(?:[.,]\d+)?") + + +def _catalog_must_tokens(query: str) -> list[list[str]]: + """AND-groups for catalog search: first word plus each number (with 9,5/10 aliases).""" + text = normalize_food_name(primary_search_query(query)) + nums = [m.replace(",", ".") for m in _SEARCH_NUM_RE.findall(text)] + words = [t for t in re.split(r"[^a-z0-9äöüß]+", _SEARCH_NUM_RE.sub(" ", text)) if len(t) >= 2] + groups: list[list[str]] = [] + if words: + groups.append([words[0]]) + for num in nums[:3]: + variants = {num, num.replace(".", ",")} + try: + value = float(num) + except ValueError: + value = None + if value is not None and (abs(value - 10) <= 0.6 or abs(value - 9.5) <= 0.6): + variants.update({"10", "9.5", "9,5"}) + groups.append(list(variants)) + return groups + + def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]: q = (query or "").strip() if not q: return [] - primary = q.split(",")[0].strip() or q + primary = primary_search_query(q) or q like_full = f"%{q}%" like_primary = f"%{primary}%" prefix = f"{primary}%" norm = normalize_food_name(primary) + extra_sql = "" + extra_params: list[str] = [] + must = _catalog_must_tokens(q) + if len(must) >= 2: + parts = [] + for group in must: + ors = " OR ".join(["name_de ILIKE %s"] * len(group)) + parts.append(f"({ors})") + extra_params.extend(f"%{v}%" for v in group) + extra_sql = " OR (" + " AND ".join(parts) + ")" cur.execute( - """ + f""" SELECT id, bls_code, name_de, name_en, catalog_kind, food_group FROM food_catalog WHERE is_active = true @@ -382,6 +426,7 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = OR COALESCE(name_en, '') ILIKE %s OR COALESCE(name_en, '') ILIKE %s OR COALESCE(bls_code, '') ILIKE %s OR lower(name_de) = %s + {extra_sql} ) ORDER BY CASE @@ -395,6 +440,7 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = ( profile_id, like_full, like_primary, like_full, like_primary, like_full, norm, + *extra_params, norm, prefix, q, limit, ), ) diff --git a/backend/data_layer/food_recipes.py b/backend/data_layer/food_recipes.py index 69e4bfc..bd46098 100644 --- a/backend/data_layer/food_recipes.py +++ b/backend/data_layer/food_recipes.py @@ -108,12 +108,12 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]: recipes = [dict(r) for r in cur.fetchall()] if not recipes: return [] - ids = [r["id"] for r in recipes] + 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 FROM food_recipe_ingredients - WHERE recipe_id = ANY(%s) + WHERE recipe_id = ANY(%s::uuid[]) ORDER BY sort_order, source_name_raw """, (ids,), @@ -122,10 +122,109 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]: for row in cur.fetchall(): by_r.setdefault(str(row["recipe_id"]), []).append(dict(row)) for rec in recipes: - rec["ingredients"] = by_r.get(str(rec["id"]), []) + rec["id"] = str(rec["id"]) + rec["ingredients"] = by_r.get(rec["id"], []) return recipes +def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None: + for rec in list_recipes(cur, profile_id): + if rec["id"] == str(recipe_id): + return rec + return None + + +def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]: + name_raw = (rec.get("name_raw") or "").strip() + norm = rec.get("name_normalized") or normalize_food_name(name_raw) + if not name_raw or not norm: + raise ValueError("Rezeptname fehlt") + try: + portions = float(rec.get("portions") or 1) + except (TypeError, ValueError): + portions = 1.0 + if portions <= 0: + portions = 1.0 + description = rec.get("description") + source = (rec.get("source") or "manual").strip() or "manual" + if recipe_id: + cur.execute( + "SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s", + (recipe_id, profile_id), + ) + if not cur.fetchone(): + raise KeyError("Rezept nicht gefunden") + cur.execute( + """ + SELECT id FROM food_recipes + WHERE profile_id = %s AND name_normalized = %s AND id <> %s + """, + (profile_id, norm, recipe_id), + ) + if cur.fetchone(): + raise ValueError("Ein Rezept mit diesem Namen existiert bereits") + cur.execute( + """ + UPDATE food_recipes + SET name_raw=%s, name_normalized=%s, portions=%s, description=%s, updated_at=NOW() + WHERE id=%s AND profile_id=%s + """, + (name_raw, norm, portions, description, recipe_id, profile_id), + ) + cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (recipe_id,)) + rid = recipe_id + else: + cur.execute( + "SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s", + (profile_id, norm), + ) + existing = cur.fetchone() + if existing: + return save_recipe(cur, profile_id, rec, str(existing["id"])) + rid = str(uuid.uuid4()) + cur.execute( + """ + INSERT INTO food_recipes + (id, profile_id, name_raw, name_normalized, portions, description, source) + VALUES (%s,%s,%s,%s,%s,%s,%s) + """, + (rid, profile_id, name_raw, norm, portions, description, source), + ) + for i, ing in enumerate(rec.get("ingredients") or []): + raw = (ing.get("source_name_raw") or "").strip() + 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 + 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) + """, + (str(uuid.uuid4()), rid, raw or inorm, inorm, ing.get("quantity_raw"), qty, i), + ) + link_recipes_to_items(cur, profile_id) + saved = get_recipe(cur, profile_id, rid) + if not saved: + raise ValueError("Rezept konnte nicht gelesen werden") + return saved + + +def delete_recipe(cur, profile_id: str, recipe_id: str) -> None: + cur.execute( + "DELETE FROM food_recipes WHERE id = %s AND profile_id = %s RETURNING id", + (recipe_id, profile_id), + ) + if not cur.fetchone(): + raise KeyError("Rezept nicht gefunden") + + def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int: cur.execute( """ diff --git a/backend/data_layer/food_suggest.py b/backend/data_layer/food_suggest.py index 9ded35b..570945a 100644 --- a/backend/data_layer/food_suggest.py +++ b/backend/data_layer/food_suggest.py @@ -5,7 +5,7 @@ import re import time from typing import Any -from data_layer.food_mapping import normalize_food_name +from data_layer.food_mapping import normalize_food_name, primary_search_query _INDEX_TTL_SEC = 300 _index_cache: dict[str, tuple[float, dict[str, Any]]] = {} @@ -14,32 +14,106 @@ MAX_BUCKET = 40 SPLIT_RE = re.compile(r"[^a-z0-9äöüß]+") COLLAPSE_RE = re.compile(r"[^a-z0-9äöüß]") +NUM_RE = re.compile(r"\d+(?:[.,]\d+)?") +NUM_TOKEN_RE = re.compile(r"^\d+(?:\.\d+)?$") MIN_SCORE = 45 +FAT_CLOSE = 0.6 def collapse_key(raw: str | None) -> str: return COLLAPSE_RE.sub("", normalize_food_name(raw)) +def number_tokens(raw: str | None) -> list[str]: + return [m.replace(",", ".") for m in NUM_RE.findall(normalize_food_name(raw))] + + +def is_number_token(tok: str) -> bool: + return bool(NUM_TOKEN_RE.fullmatch(tok or "")) + + +def number_search_aliases(tok: str) -> list[str]: + """9.5 and 10 are the same fat class in dairy; keep both searchable.""" + if not is_number_token(tok): + return [tok] + aliases = {tok} + try: + value = float(tok) + except ValueError: + return [tok] + if abs(value - 10) <= FAT_CLOSE or abs(value - 9.5) <= FAT_CLOSE: + aliases.update({"9.5", "10"}) + if value == int(value): + aliases.add(str(int(value))) + return list(aliases) + + +def numbers_compatible(query_nums: list[str], name_nums: list[str]) -> bool | None: + if not query_nums: + return None + if not name_nums: + return False + for qn in query_nums: + try: + qv = float(qn) + except ValueError: + continue + for nn in name_nums: + try: + if abs(qv - float(nn)) <= FAT_CLOSE: + return True + except ValueError: + if qn == nn: + return True + return False + + def name_tokens(raw: str | None) -> list[str]: - return [t for t in SPLIT_RE.split(normalize_food_name(raw)) if len(t) >= 2] + text = normalize_food_name(raw) + nums = number_tokens(text) + words = [t for t in SPLIT_RE.split(NUM_RE.sub(" ", text)) if len(t) >= 2] + return words + nums def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int: - qn = normalize_food_name((query or "").split(",")[0]) + qn = normalize_food_name(primary_search_query(query)) nn = normalize_food_name(name_de) if not qn or not nn: return 0 qc, nc = collapse_key(qn), collapse_key(nn) + q_nums, n_nums = number_tokens(qn), number_tokens(nn) + fat_ok = numbers_compatible(q_nums, n_nums) if qn == nn: return 100 if qc and qc == nc: return 95 + qt, nt = set(name_tokens(qn)), set(name_tokens(nn)) + q_words = {t for t in qt if not is_number_token(t)} + n_words = {t for t in nt if not is_number_token(t)} + words_overlap = bool(q_words and n_words and (q_words & n_words or q_words <= n_words)) + if fat_ok and words_overlap: + return 96 + if fat_ok is False: + base = 0 + if qc and nc.startswith(qc) and len(qc) >= 4: + base = 82 + elif nc and qc.startswith(nc) and len(nc) >= 4: + base = 78 + elif qt and qt <= nt: + base = 72 + elif nt and nt <= qt: + base = 68 + elif qt and nt: + overlap = len(qt & nt) / len(qt | nt) + if overlap >= 0.5: + base = 50 + int(overlap * 20) + elif qc and nc and len(qc) >= 4 and (qc in nc or nc in qc): + base = 55 if abs(len(qc) - len(nc)) <= 8 else 46 + return min(base, 52) if base else 0 if qc and nc.startswith(qc) and len(qc) >= 4: return 82 if nc and qc.startswith(nc) and len(nc) >= 4: return 78 - qt, nt = set(name_tokens(qn)), set(name_tokens(nn)) if qt and qt <= nt: return 72 if nt and nt <= qt: @@ -148,15 +222,27 @@ def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]: if len(suffix_hits) <= MAX_BUCKET: for food in suffix_hits: add(food) + q_words = [t for t in name_tokens(query) if not is_number_token(t)] + seen_tok: set[str] = set() for tok in name_tokens(query): - token_hits = index["by_token"].get(tok, []) - if len(token_hits) > MAX_BUCKET: - token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET] - for food in token_hits: - add(food) - if len(out) >= MAX_CANDIDATES: + probes = number_search_aliases(tok) if is_number_token(tok) else [tok] + for probe in probes: + if probe in seen_tok: + continue + seen_tok.add(probe) + token_hits = index["by_token"].get(probe, []) + if is_number_token(probe) and q_words: + token_hits = [ + food for food in token_hits + if set(q_words) & set(food.get("_t") or []) + ] + elif not is_number_token(probe) and len(token_hits) > MAX_BUCKET: + token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET] + for food in token_hits: + add(food) + if len(out) >= MAX_CANDIDATES and not any(is_number_token(t) for t in name_tokens(query)): break - return out[:MAX_CANDIDATES] + return out[:MAX_CANDIDATES] if not number_tokens(query) else out def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]: @@ -196,11 +282,22 @@ def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit: if len(q) < 2: return [] index = get_suggest_index(cur, profile_id) - packed = suggest_for_name(index, q, limit=limit) - if packed["suggestions"]: - return packed["suggestions"] + packed = suggest_for_name(index, q, limit=max(limit, 8)) + hits = list(packed["suggestions"]) from data_layer.food_mapping import suggest_catalog_foods - return suggest_catalog_foods(cur, q, profile_id, limit=limit) + if number_tokens(q) or not hits: + seen = {str(h["id"]) for h in hits} + for row in suggest_catalog_foods(cur, q, profile_id, limit=max(limit, 20)): + fid = str(row["id"]) + if fid in seen: + continue + seen.add(fid) + score = score_name_match(q, row.get("name_de") or "", row.get("name_en")) + if score < MIN_SCORE and not number_tokens(q): + continue + hits.append(_public(row, score or 40)) + hits.sort(key=lambda h: (-int(h.get("score") or 0), h.get("name_de") or "")) + return hits[:limit] def suggest_batch(cur, profile_id: str | None, names: list[str], limit: int = 3) -> dict[str, dict[str, Any]]: diff --git a/backend/migrations/065_food_attribute_omega_extensions.sql b/backend/migrations/065_food_attribute_omega_extensions.sql new file mode 100644 index 0000000..5abffec --- /dev/null +++ b/backend/migrations/065_food_attribute_omega_extensions.sql @@ -0,0 +1,13 @@ +-- Common supplement fatty acids if BLS did not import these exact keys. +-- ON CONFLICT keeps official BLS rows unchanged. + +INSERT INTO food_attributes + (attr_key, name_de, name_en, unit, category, data_type, origin, sort_order) +VALUES + ('EPA', 'Eicosapentaensäure (EPA)', 'EPA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8001), + ('DHA', 'Docosahexaensäure (DHA)', 'DHA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8002), + ('DPA', 'Docosapentaensäure (DPA)', 'DPA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8003), + ('ALA', 'Alpha-Linolensäure (ALA)', 'ALA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8004), + ('OMEGA3', 'Omega-3-Fettsäuren gesamt', 'Omega-3', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8005), + ('OMEGA6', 'Omega-6-Fettsäuren gesamt', 'Omega-6', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8006) +ON CONFLICT (attr_key) DO NOTHING; diff --git a/backend/routers/admin_bls.py b/backend/routers/admin_bls.py index 310000f..8546512 100644 --- a/backend/routers/admin_bls.py +++ b/backend/routers/admin_bls.py @@ -264,21 +264,5 @@ def admin_create_attribute(body: AttributeCreate, session: dict = Depends(requir def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None: - if not macros: - return - mapping = {"kcal": "ENERCC", "protein_g": "PROT625", "fat_g": "FAT", "carbs_g": "CHO"} - for field, key in mapping.items(): - if field not in macros or macros[field] is None: - continue - cur.execute("SELECT id FROM food_attributes WHERE attr_key = %s", (key,)) - row = cur.fetchone() - if not row: - continue - cur.execute( - """ - INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace) - VALUES (%s, %s, %s, false) - ON CONFLICT (food_id, attribute_id) DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW() - """, - (food_id, row["id"], float(macros[field])), - ) + from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes + write_numeric_attributes(cur, food_id, macros_and_attributes_to_values(macros, None)) diff --git a/backend/routers/admin_food_mappings.py b/backend/routers/admin_food_mappings.py index cdd8459..dbdfc2c 100644 --- a/backend/routers/admin_food_mappings.py +++ b/backend/routers/admin_food_mappings.py @@ -7,7 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from auth import require_admin -from data_layer.food_mapping import upsert_food_mapping +from data_layer.food_mapping import apply_mapping_to_items, upsert_food_mapping +from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients from db import get_cursor, get_db, r2d router = APIRouter(prefix="/api/admin/food-mappings", tags=["admin", "food-mappings"]) @@ -20,6 +21,12 @@ class FoodMappingCreate(BaseModel): source_system: str = "fddb" +class FoodMappingUpdate(BaseModel): + food_id: str + grams_per_unit: Optional[float] = None + source_unit: Optional[str] = None + + @router.get("") def list_food_mappings( profile_id: Optional[str] = None, @@ -93,6 +100,49 @@ def create_food_mapping(body: FoodMappingCreate, session: dict = Depends(require return r2d(cur.fetchone()) +@router.put("/{mapping_id}") +def update_food_mapping(mapping_id: int, body: FoodMappingUpdate, session: dict = Depends(require_admin)): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, source_name_raw, source_name_normalized, profile_id + FROM food_name_mappings WHERE id = %s + """, + (mapping_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Mapping nicht gefunden") + cur.execute("SELECT id FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Lebensmittel nicht gefunden") + cur.execute( + """ + UPDATE food_name_mappings + SET food_id = %s, grams_per_unit = COALESCE(%s, grams_per_unit), + source_unit = COALESCE(%s, source_unit), source = 'admin', updated_at = NOW() + WHERE id = %s + """, + (body.food_id, body.grams_per_unit, body.source_unit, mapping_id), + ) + if row.get("profile_id"): + apply_mapping_to_items(cur, str(row["profile_id"]), row["source_name_normalized"], body.food_id, mapping_id) + dates = dates_for_normalized_name(cur, str(row["profile_id"]), row["source_name_normalized"]) + for d in dates: + rebuild_daily_nutrients(cur, str(row["profile_id"]), d) + cur.execute( + """ + SELECT m.*, f.name_de AS food_name_de, f.bls_code + FROM food_name_mappings m + JOIN food_catalog f ON f.id = m.food_id + WHERE m.id = %s + """, + (mapping_id,), + ) + return r2d(cur.fetchone()) + + @router.delete("/{mapping_id}") def delete_food_mapping(mapping_id: int, session: dict = Depends(require_admin)): with get_db() as conn: diff --git a/backend/routers/bls.py b/backend/routers/bls.py index c7e42f1..13dc46a 100644 --- a/backend/routers/bls.py +++ b/backend/routers/bls.py @@ -34,6 +34,8 @@ class UserFoodCreate(BaseModel): name_de: str name_en: Optional[str] = None macros_per_100g: Optional[dict] = None + attributes: Optional[dict] = None + serving_g: Optional[float] = None class MappingUpsert(BaseModel): @@ -89,6 +91,17 @@ def search_foods( return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50)) +@router.get("/attributes") +def list_food_attributes( + q: str = "", + limit: int = 40, + session: dict = Depends(require_auth), +): + from data_layer.food_attributes import list_numeric_attributes + with get_db() as conn: + return list_numeric_attributes(get_cursor(conn), q, limit=limit) + + @router.post("/foods/suggest-batch") def suggest_foods_batch( body: SuggestBatchBody, @@ -107,13 +120,15 @@ def create_user_food( x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): - from routers.admin_bls import _write_manual_macros + from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes import uuid pid = _pid(session, x_profile_id) name = (body.name_de or "").strip() if not name: raise HTTPException(400, "Name fehlt") + serving = body.serving_g if body.serving_g and body.serving_g > 0 else None + values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving) with get_db() as conn: cur = get_cursor(conn) cur.execute( @@ -126,7 +141,7 @@ def create_user_food( (name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:32]}-{uuid.uuid4().hex[:8]}"), ) food = r2d(cur.fetchone()) - _write_manual_macros(cur, food["id"], body.macros_per_100g) + write_numeric_attributes(cur, food["id"], values) invalidate_suggest_index(pid) return food diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 438a697..76a7b2d 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -12,6 +12,7 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from fastapi.responses import Response +from pydantic import BaseModel from db import get_db, get_cursor, r2d from auth import require_auth, check_feature_access, increment_feature_usage @@ -23,6 +24,19 @@ router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) logger = logging.getLogger(__name__) +class RecipeIngredientIn(BaseModel): + source_name_raw: str + quantity_raw: Optional[str] = None + quantity_g: Optional[float] = None + + +class RecipeIn(BaseModel): + name_raw: str + portions: float = 1 + description: Optional[str] = None + ingredients: list[RecipeIngredientIn] = [] + + # ── Helper ──────────────────────────────────────────────────────────────────── def _pf(s): """Parse float from string (handles comma decimal separator).""" @@ -441,6 +455,55 @@ def list_food_recipes( return list_recipes(get_cursor(conn), pid) +@router.post("/recipes") +def create_food_recipe( + body: RecipeIn, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_recipes import save_recipe + pid = get_pid(x_profile_id) + try: + with get_db() as conn: + return save_recipe(get_cursor(conn), pid, body.model_dump()) + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.put("/recipes/{recipe_id}") +def update_food_recipe( + recipe_id: str, + body: RecipeIn, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_recipes import save_recipe + pid = get_pid(x_profile_id) + try: + with get_db() as conn: + return save_recipe(get_cursor(conn), pid, body.model_dump(), recipe_id) + except KeyError: + raise HTTPException(404, "Rezept nicht gefunden") from None + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.delete("/recipes/{recipe_id}") +def delete_food_recipe( + recipe_id: str, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_recipes import delete_recipe + pid = get_pid(x_profile_id) + try: + with get_db() as conn: + delete_recipe(get_cursor(conn), pid, recipe_id) + except KeyError: + raise HTTPException(404, "Rezept nicht gefunden") from None + return {"ok": True} + + @router.post("/recipes/import-fddb-lists") async def import_fddb_lists( file: UploadFile = File(...), @@ -506,9 +569,13 @@ def export_food_knowledge_file( from data_layer.food_knowledge import export_food_knowledge pid = get_pid(x_profile_id) - with get_db() as conn: - bundle = export_food_knowledge(get_cursor(conn), pid) - body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str) + try: + with get_db() as conn: + bundle = export_food_knowledge(get_cursor(conn), pid) + body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str) + except Exception: + logger.exception("Export food-knowledge fehlgeschlagen") + raise HTTPException(500, "Export fehlgeschlagen") from None stamp = datetime.now().strftime("%Y-%m-%d") return Response( content=body.encode("utf-8"), diff --git a/backend/tests/test_food_attributes.py b/backend/tests/test_food_attributes.py new file mode 100644 index 0000000..7bb2883 --- /dev/null +++ b/backend/tests/test_food_attributes.py @@ -0,0 +1,19 @@ +from data_layer.food_attributes import macros_and_attributes_to_values, scale_to_per_100g + + +def test_scale_serving_to_per_100g(): + assert scale_to_per_100g(1.1, 8) == 1.1 * (100 / 8) + assert scale_to_per_100g(10, 100) == 10 + assert scale_to_per_100g(10, None) == 10 + + +def test_norsan_label_epa_converts(): + """Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g.""" + vals = macros_and_attributes_to_values( + {"kcal": 72, "protein_g": 0, "fat_g": 8, "carbs_g": 0}, + {"EPA": 1.1}, + serving_g=8, + ) + assert abs(vals["FAT"] - 100) < 0.01 + assert abs(vals["EPA"] - 13.75) < 0.01 + assert abs(vals["ENERCC"] - 900) < 0.01 diff --git a/backend/tests/test_food_knowledge.py b/backend/tests/test_food_knowledge.py index caa5188..9e49e9c 100644 --- a/backend/tests/test_food_knowledge.py +++ b/backend/tests/test_food_knowledge.py @@ -1,5 +1,9 @@ +from decimal import Decimal +from uuid import uuid4 + from data_layer.food_knowledge import ( BUNDLE_FORMAT, + _jsonable, parse_food_knowledge_bundle, portable_mapping, ) @@ -40,3 +44,14 @@ def test_portable_mapping_drops_ids(): assert "food_id" not in row assert row["bls_code"] == "C131000" assert row["source_name_raw"] == "Haferflocken, Großblatt" + + +def test_jsonable_export_types(): + payload = _jsonable({ + "grams": Decimal("1.50"), + "id": uuid4(), + "nested": [Decimal("2")], + }) + assert isinstance(payload["grams"], float) + assert isinstance(payload["id"], str) + assert payload["nested"] == [2.0] diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index fbe68a3..c9c03b5 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -7,10 +7,16 @@ from data_layer.food_mapping import ( normalize_food_name, parse_quantity, parse_quantity_g, + primary_search_query, ) from data_layer.nutrition_items import macros_differ +def test_primary_query_keeps_decimal_comma(): + assert primary_search_query("Joghurt 9,5%") == "Joghurt 9,5%" + assert primary_search_query("Joghurt, aus Kuhmilch") == "Joghurt" + + def test_normalize_strips_leading_quantity(): assert normalize_food_name("50 g Hähnchen") == "hähnchen" assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%" diff --git a/backend/tests/test_food_recipes.py b/backend/tests/test_food_recipes.py new file mode 100644 index 0000000..166853a --- /dev/null +++ b/backend/tests/test_food_recipes.py @@ -0,0 +1,10 @@ +from data_layer.food_recipes import save_recipe + + +def test_save_recipe_requires_name(): + try: + save_recipe(None, "p", {"name_raw": " ", "ingredients": []}) + except ValueError as e: + assert "Rezeptname" in str(e) + else: + raise AssertionError("expected ValueError") diff --git a/backend/tests/test_food_suggest.py b/backend/tests/test_food_suggest.py index 93295d4..3c4dc03 100644 --- a/backend/tests/test_food_suggest.py +++ b/backend/tests/test_food_suggest.py @@ -1,4 +1,25 @@ -from data_layer.food_suggest import collapse_key, score_name_match, suggest_batch, suggest_for_name +from data_layer.food_suggest import ( + collapse_key, + name_tokens, + score_name_match, + suggest_batch, + suggest_for_name, +) + + +def _index(*foods): + index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}} + for food in foods: + food["_c"] = collapse_key(food["name_de"]) + food["_t"] = name_tokens(food["name_de"]) + if food["_c"]: + index["by_collapse"].setdefault(food["_c"], []).append(food) + index["by_prefix"].setdefault(food["_c"][:4], []).append(food) + if len(food["_c"]) >= 4: + index["by_suffix"].setdefault(food["_c"][-4:], []).append(food) + for tok in food["_t"]: + index["by_token"].setdefault(tok, []).append(food) + return index def test_collapse_treats_space_as_same(): @@ -63,3 +84,24 @@ def test_suggest_batch_dedupes_names(): out = suggest_batch(_Cur(), None, ["Haferflocken", "Haferflocken", ""], limit=2) assert "Haferflocken" in out assert out["Haferflocken"]["suggestions"] == [] + + +def test_number_tokens_keep_decimal_fat(): + assert "9.5" in name_tokens("Joghurt 9,5%") + assert "10" in name_tokens("Joghurt, aus Kuhmilch, 10 % Fett") + + +def test_joghurt_10_ranks_above_35(): + light = {"id": "a", "name_de": "Joghurt >3,5% Fett", "name_en": "", "bls_code": "M", "catalog_kind": "official_bls"} + full = { + "id": "b", + "name_de": "Joghurt, aus Kuhmilch, 10 % Fett", + "name_en": "", + "bls_code": "N", + "catalog_kind": "official_bls", + } + index = _index(light, full) + for query in ("Joghurt 10%", "Joghurt 9,5%", "Joghurt 10"): + packed = suggest_for_name(index, query, limit=3) + assert packed["suggestions"][0]["name_de"] == full["name_de"], query + assert packed["suggestions"][0]["score"] > score_name_match(query, light["name_de"]) diff --git a/backend/version.py b/backend/version.py index 28e8dd4..8973af8 100644 --- a/backend/version.py +++ b/backend/version.py @@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH APP_VERSION = "0.9v" BUILD_DATE = "2026-09-12" -DB_SCHEMA_VERSION = "20260912" # 064 mapping units +DB_SCHEMA_VERSION = "20260912b" # 065 omega attribute extensions 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.5", # unmapped since_days: last weeks first - "bls": "1.0.2", + "nutrition": "1.2.9", # Zuordnen: Lebensmittel oder Rezept anlegen + "bls": "1.0.4", "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", @@ -52,6 +52,9 @@ CHANGELOG = [ "Inline-Vorschläge (Haferflocken → Hafer Flocken), Bestätigen in der Zeile, Neu anlegen", "Zuordnen: Suche und Bestätigen ohne Browser-Freeze (Index-Cache, Batch-Vorschläge, kein Seiten-Reload)", "Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen", + "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", ], }, { diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md index 473ae25..1f56c00 100644 --- a/docs/issues/issue-bls-food-mapping.md +++ b/docs/issues/issue-bls-food-mapping.md @@ -18,3 +18,4 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende - FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen 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 diff --git a/frontend/src/components/FoodNutrientFields.jsx b/frontend/src/components/FoodNutrientFields.jsx new file mode 100644 index 0000000..e92a670 --- /dev/null +++ b/frontend/src/components/FoodNutrientFields.jsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../utils/api' + +function parseNum(raw) { + const n = parseFloat(String(raw ?? '').replace(',', '.')) + return Number.isFinite(n) ? n : 0 +} + +export function foodCreatePayload(name, macros, extras, servingG) { + const attributes = {} + for (const row of extras || []) { + if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue + attributes[row.attr.attr_key] = parseNum(row.value) + } + const serving = parseNum(servingG) + return { + name_de: (name || '').trim(), + macros_per_100g: { + kcal: parseNum(macros.kcal), + protein_g: parseNum(macros.protein_g), + fat_g: parseNum(macros.fat_g), + carbs_g: parseNum(macros.carbs_g), + }, + attributes, + serving_g: serving > 0 && serving !== 100 ? serving : null, + } +} + +export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) { + const [q, setQ] = useState('') + const [hits, setHits] = useState([]) + const [loading, setLoading] = useState(false) + const timer = useRef(null) + + useEffect(() => () => clearTimeout(timer.current), []) + + const search = (term) => { + clearTimeout(timer.current) + const query = (term || '').trim() + if (query.length < 2) { + setHits([]) + return + } + timer.current = setTimeout(async () => { + setLoading(true) + try { + setHits(await api.listFoodAttributes(query, 20)) + } catch { + setHits([]) + } finally { + setLoading(false) + } + }, 250) + } + + const add = (attr) => { + setExtras((list) => ( + list.some((r) => r.attr.attr_key === attr.attr_key) ? list : [...list, { attr, value: '' }] + )) + setQ('') + setHits([]) + } + + const setValue = (key, value) => { + setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r))) + } + + return ( +
+

+ Zahlen in der Einheit des Stoffs. Bei Etikett „pro 8 ml / 8 g“ die Portionsgröße setzen — gespeichert wird immer pro 100 g. +

+ +
+ {[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => ( + + ))} +
+

Weitere Stoffe (EPA, DHA, Ballaststoffe, …)

+ { setQ(e.target.value); search(e.target.value) }} + /> + {loading &&

Suche…

} + {hits.map((h) => ( + + ))} + {extras.map((row) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/FoodRecipeEditor.jsx b/frontend/src/components/FoodRecipeEditor.jsx new file mode 100644 index 0000000..16475b6 --- /dev/null +++ b/frontend/src/components/FoodRecipeEditor.jsx @@ -0,0 +1,183 @@ +import { useState } from 'react' +import { api } from '../utils/api' + +function emptyIng() { + return { source_name_raw: '', quantity_g: '' } +} + +function mappingHint(name, learned) { + const n = (name || '').trim().toLowerCase() + if (!n) return null + return (learned || []).find((m) => { + const raw = (m.source_name_raw || '').toLowerCase() + const norm = (m.source_name_normalized || '').toLowerCase() + return raw === n || norm === n || raw.includes(n) || n.includes(norm) + }) || null +} + +export function RecipeForm({ defaultName, defaultPortions, defaultIngredients, learned, disabled, submitLabel, onSave, onCancel }) { + const [name, setName] = useState(defaultName || '') + const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1') + const [ings, setIngs] = useState( + (defaultIngredients || []).length + ? defaultIngredients.map((i) => ({ source_name_raw: i.source_name_raw || '', quantity_g: i.quantity_g ?? '' })) + : [emptyIng()] + ) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + const save = async () => { + const ingredients = ings + .map((i) => ({ + source_name_raw: (i.source_name_raw || '').trim(), + quantity_g: i.quantity_g === '' ? null : parseFloat(String(i.quantity_g).replace(',', '.')), + })) + .filter((i) => i.source_name_raw) + if (!name.trim()) { + setError('Rezeptname fehlt') + return + } + if (!ingredients.length) { + setError('Mindestens eine Zutat angeben') + return + } + setSaving(true) + setError(null) + try { + await onSave({ + name_raw: name.trim(), + portions: parseFloat(String(portions).replace(',', '.')) || 1, + ingredients, + }) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + return ( +
+

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

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

Zutaten (Name + Gramm)

+ {ings.map((ing, i) => { + const mapped = mappingHint(ing.source_name_raw, learned) + return ( +
+
+ setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value } : x))} /> + setIngs((list) => list.map((x, j) => j === i ? { ...x, quantity_g: e.target.value } : x))} /> +
+ {ing.source_name_raw.trim() && ( +
+ {mapped ? `zugeordnet: ${mapped.food_name_de || mapped.source_name_raw}` : 'noch offen — nach dem Speichern in der Liste zuordnen'} +
+ )} +
+ ) + })} + +
+ {onCancel && ( + + )} + +
+
+ ) +} + +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) + } + + const save = async (body) => { + 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 + try { + await api.deleteNutritionRecipe(id) + if (editing && editing !== 'new' && editing.id === id) setEditing(null) + onChanged?.() + } catch (e) { + setError(e.message) + } + } + + return ( +
+
+

Eigene Rezepte / Listen ({recipes.length})

+ +
+ {open && ( +
+ {error &&
{error}
} + + {recipes.map((r) => ( +
+
{r.name_raw}
+
+ {(r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen + {r.source === 'manual' ? ' · selbst angelegt' : ''} +
+
+ + +
+
+ ))} + {editing === 'new' && ( + setEditing(null)} /> + )} + {editing && editing !== 'new' && ( + setEditing(null)} + /> + )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/FoodSearchModal.jsx b/frontend/src/components/FoodSearchModal.jsx index 63d31cb..143c46b 100644 --- a/frontend/src/components/FoodSearchModal.jsx +++ b/frontend/src/components/FoodSearchModal.jsx @@ -1,5 +1,7 @@ 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 = { @@ -24,17 +26,20 @@ function detectUnit(...texts) { return null } -export default function FoodSearchModal({ title, initialQuery, quantityHint, onSelect, onClose }) { +export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, learned, onSelect, onCreateRecipe, onClose }) { const [q, setQ] = useState(initialQuery || '') const [hits, setHits] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [creating, setCreating] = useState(false) const [showCreate, setShowCreate] = useState(false) + const [createKind, setCreateKind] = useState(null) const [manual, setManual] = useState({ name_de: initialQuery || '', kcal: '', protein_g: '', fat_g: '', carbs_g: '', }) + const [nutrientExtras, setNutrientExtras] = useState([]) + const [servingG, setServingG] = useState('100') const suggestedUnit = detectUnit(quantityHint, initialQuery) const [gramsPerUnit, setGramsPerUnit] = useState( suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : '' @@ -44,7 +49,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS const abortRef = useRef(null) const seqRef = useRef(0) - const extras = () => { + const unitExtras = () => { const g = parseFloat(String(gramsPerUnit).replace(',', '.')) if (!suggestedUnit || !g || g <= 0) return {} return { grams_per_unit: g, source_unit: suggestedUnit } @@ -111,16 +116,8 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS setCreating(true) setError(null) try { - const food = await api.createUserFood({ - name_de: name, - macros_per_100g: { - kcal: parseFloat(String(manual.kcal).replace(',', '.')) || 0, - protein_g: parseFloat(String(manual.protein_g).replace(',', '.')) || 0, - fat_g: parseFloat(String(manual.fat_g).replace(',', '.')) || 0, - carbs_g: parseFloat(String(manual.carbs_g).replace(',', '.')) || 0, - }, - }) - onSelect(food, extras()) + const food = await api.createUserFood(foodCreatePayload(name, manual, nutrientExtras, servingG)) + onSelect(food, unitExtras()) } catch (e) { setError(e.message) } finally { @@ -160,12 +157,12 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS ref={inputRef} className="form-input" style={{ width: '100%', textAlign: 'left' }} - placeholder="Name eingeben, z. B. Haferflocken" + placeholder="Name und Fettgehalt, z. B. Joghurt 10%" value={q} onChange={(e) => onChange(e.target.value)} />

- Suche nach dem Namen. Fehlt der Treffer, legst du unten einen eigenen Eintrag an. + Fettgehalt mit eingeben, z. B. Joghurt 10 % oder 9,5 %. Ohne Zahl erscheint oft nur die 3,5 %-Klasse. Fehlt der Treffer, eigenen Eintrag anlegen.

{suggestedUnit && (