diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 8a82b22..433e4ba 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -48,6 +48,8 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide 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: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute (Bezeichnung änderbar, Schlüssel fest). In die Makro-Haupttabelle (`nutrition_log`) fließen nur ENERCC→kcal, PROT625→protein_g, FAT→fat_g, CHO→carbs_g (Wert je 100 g × gegessene g / 100). Andere Stoffe gehen nach `nutrition_daily_nutrients`. +**Listen am Tag:** Eine Mitai-Liste (z. B. Quark-Skyr-Blaubeeren) ist zugeordnet, sobald die Tagebuchzeile `recipe_id` hat — nicht erst bei einem einzelnen `food_id`. In der Tagesübersicht erscheint „Liste: …“. Sind alle Zutaten gemappt, lassen sich die berechneten Katalog-Makros (Summe der Zutaten, skaliert auf die gegessene Menge) per Knopf in `nutrition_log` übernehmen. + **Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar. Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle. diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index afbfc7c..975bf4d 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -48,4 +48,5 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - `GET /nutrition/ingredient-names` — Distinct-Zutatennamen aller Listen inkl. Mapping-Status. Listendialog mappt manuell (`upsertMyFoodMapping`) an der Zeile. - 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**) +- `GET /nutrition/items` liefert `recipe_name_de` + `recipe_complete`. `GET /nutrition/days/{date}/macros` vergleicht bestehende vs. Katalog-Tagesmakros; `POST …/apply-catalog-macros` schreibt die berechnete Summe nach `nutrition_log`. Listen-Save verknüpft passende Tagebuchzeilen (`items_linked`). - `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein). diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index 97ec9ad..ce6f716 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -10,6 +10,10 @@ LEADING_QTY_RE = re.compile( r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-–]*", re.IGNORECASE, ) +TRAILING_QTY_RE = re.compile( + r"[\s,.:\-–]+\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l)\s*$", + re.IGNORECASE, +) MULTISPACE_RE = re.compile(r"\s+") DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)") @@ -27,8 +31,10 @@ def normalize_food_name(raw: str | None) -> str: s = strip_leading_quantity(raw) if not s: return "" + s = TRAILING_QTY_RE.sub("", s) + s = s.replace("-", " ") s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s) - return s.lower() + return MULTISPACE_RE.sub(" ", s).strip().lower() def item_name_keys(raw: str | None, stored_norm: str | None = None) -> set[str]: diff --git a/backend/data_layer/food_recipes.py b/backend/data_layer/food_recipes.py index 6924ae8..d056e0e 100644 --- a/backend/data_layer/food_recipes.py +++ b/backend/data_layer/food_recipes.py @@ -326,10 +326,12 @@ def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i, ), ) - link_recipes_to_items(cur, profile_id) + linked, dates = link_recipes_to_items(cur, profile_id) saved = get_recipe(cur, profile_id, rid) if not saved: raise ValueError("Rezept konnte nicht gelesen werden") + saved["items_linked"] = linked + saved["dates_linked"] = dates return saved diff --git a/backend/data_layer/nutrition_items.py b/backend/data_layer/nutrition_items.py index 91746b3..f0bc734 100644 --- a/backend/data_layer/nutrition_items.py +++ b/backend/data_layer/nutrition_items.py @@ -446,6 +446,65 @@ def _touch_item_counts(cur, profile_id: str, day: str, sums: dict) -> None: ) +def day_macro_preview(cur, profile_id: str, day: date | str) -> dict[str, Any]: + sums = compute_day_macro_sums(cur, profile_id, day) + catalog = sums.get("catalog") + existing = sums.get("existing") + mapped = int(sums.get("mapped_item_count") or 0) + return { + "date": str(day)[:10], + "existing": existing, + "catalog": catalog, + "fddb": sums.get("fddb"), + "mapped_item_count": mapped, + "unmapped_item_count": int(sums.get("unmapped_item_count") or 0), + "catalog_origin": sums.get("catalog_origin"), + "can_apply": bool(catalog) and mapped > 0, + "differs": bool(existing and catalog and macros_differ(existing, catalog)), + } + + +def apply_day_catalog_macros(cur, profile_id: str, day: date | str) -> dict[str, Any]: + preview = day_macro_preview(cur, profile_id, day) + if not preview["can_apply"]: + raise ValueError("Keine aus Zuordnungen berechenbaren Tagesmakros") + cur.execute( + "SELECT source FROM nutrition_log WHERE profile_id = %s AND date = %s", + (profile_id, day), + ) + row = cur.fetchone() + source = (row.get("source") if row else None) or "csv" + apply_nutrition_day_macros( + cur, + profile_id, + day, + preview["catalog"], + macro_origin=preview.get("catalog_origin") or "bls", + source=source, + confirm=True, + ) + rebuild_daily_nutrients(cur, profile_id, day) + preview = day_macro_preview(cur, profile_id, day) + preview["applied"] = True + return preview + + +def annotate_nutrition_item(cur, profile_id: str, row: dict[str, Any]) -> dict[str, Any]: + item = dict(row) + item["id"] = str(item["id"]) + if item.get("food_id"): + item["food_id"] = str(item["food_id"]) + if item.get("recipe_id"): + item["recipe_id"] = str(item["recipe_id"]) + from data_layer.food_recipes import catalog_macros_for_recipe + item["recipe_complete"] = catalog_macros_for_recipe( + cur, profile_id, item["recipe_id"], item.get("quantity_g") + ) is not None + else: + item["recipe_complete"] = False + return item + + def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]: if choice == "existing": return sums["existing"], "user_confirmed" diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index e8d1ca8..8daaa88 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -339,9 +339,11 @@ def list_nutrition_items( if date: cur.execute( """ - SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind + SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind, + r.name_raw AS recipe_name_de FROM nutrition_items i LEFT JOIN food_catalog f ON f.id = i.food_id + LEFT JOIN food_recipes r ON r.id = i.recipe_id WHERE i.profile_id=%s AND i.date=%s ORDER BY i.logged_at NULLS LAST, i.source_name_raw """, @@ -350,16 +352,19 @@ def list_nutrition_items( else: cur.execute( """ - SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind + SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind, + r.name_raw AS recipe_name_de FROM nutrition_items i LEFT JOIN food_catalog f ON f.id = i.food_id + LEFT JOIN food_recipes r ON r.id = i.recipe_id WHERE i.profile_id=%s ORDER BY i.date DESC, i.logged_at NULLS LAST LIMIT %s """, (pid, min(limit, 500)), ) - return [r2d(r) for r in cur.fetchall()] + from data_layer.nutrition_items import annotate_nutrition_item + return [annotate_nutrition_item(cur, pid, r2d(r)) for r in cur.fetchall()] @router.get("/unmapped") @@ -422,10 +427,13 @@ def create_food_recipe( session: dict = Depends(require_auth), ): from data_layer.food_recipes import save_recipe + from data_layer.nutrition_items import schedule_daily_nutrient_rebuild pid = get_pid(x_profile_id) try: with get_db() as conn: - return save_recipe(get_cursor(conn), pid, body.model_dump()) + saved = save_recipe(get_cursor(conn), pid, body.model_dump()) + schedule_daily_nutrient_rebuild(pid, saved.get("dates_linked") or [], "Liste angelegt") + return saved except ValueError as e: raise HTTPException(400, str(e)) from e @@ -438,10 +446,13 @@ def update_food_recipe( session: dict = Depends(require_auth), ): from data_layer.food_recipes import save_recipe + from data_layer.nutrition_items import schedule_daily_nutrient_rebuild pid = get_pid(x_profile_id) try: with get_db() as conn: - return save_recipe(get_cursor(conn), pid, body.model_dump(), recipe_id) + saved = save_recipe(get_cursor(conn), pid, body.model_dump(), recipe_id) + schedule_daily_nutrient_rebuild(pid, saved.get("dates_linked") or [], "Liste geändert") + return saved except KeyError: raise HTTPException(404, "Rezept nicht gefunden") from None except ValueError as e: @@ -612,6 +623,35 @@ def resolve_import_conflicts( return {"applied": applied} +@router.get("/days/{day}/macros") +def get_day_macros( + day: str, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.nutrition_items import day_macro_preview + + pid = get_pid(x_profile_id) + with get_db() as conn: + return day_macro_preview(get_cursor(conn), pid, day) + + +@router.post("/days/{day}/apply-catalog-macros") +def apply_catalog_macros_for_day( + day: str, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.nutrition_items import apply_day_catalog_macros + + pid = get_pid(x_profile_id) + try: + with get_db() as conn: + return apply_day_catalog_macros(get_cursor(conn), pid, day) + except ValueError as e: + raise HTTPException(400, str(e)) from e + + @router.put("/days/{day}/mark") def upsert_day_mark( day: str, diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index ab709ea..2ae1082 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -137,6 +137,11 @@ def test_list_name_matches_despite_quantity_prefix(): assert not names_match_list("Haferflocken", "haferflocken", "müsli mix") +def test_list_name_matches_hyphen_and_trailing_qty(): + assert names_match_list("Quark-Skyr Blaubeeren", "quark-skyr blaubeeren", "quark skyr blaubeeren") + assert names_match_list("Quark skyr Blaubeeren 250 g", None, "Quark skyr Blaubeeren") + + def test_unmapped_meta_includes_recent_ingredients(): batches = [ [], diff --git a/backend/version.py b/backend/version.py index 6dcc44a..e76ca9d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -20,7 +20,7 @@ MODULE_VERSIONS = { "circumference": "1.0.1", "caliper": "1.0.1", "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement - "nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping + "nutrition": "1.3.8", # Listen in Tagesübersicht; Katalog-Makros auf den Tag übernehmen "tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet) "bls": "1.0.13", # Stoffe gruppiert und sortiert (BLS-Nährstoffgruppen) diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 6d1b3c8..ee92b28 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -197,25 +197,63 @@ function EntryForm({ onSaved }) { } // ── Data Tab (Editable Entry List) ─────────────────────────────────────────── -function DayItems({ date }) { +function itemAssignment(it) { + if (it.food_name_de) return { ok: true, text: `→ ${it.food_name_de}` } + if (it.recipe_name_de || it.recipe_id) { + const name = it.recipe_name_de || 'Liste' + if (it.recipe_complete) return { ok: true, text: `→ Liste: ${name}` } + return { ok: false, text: `→ Liste: ${name} (Zutaten noch offen)` } + } + return { ok: false, text: '· noch nicht zugeordnet' } +} + +function formatMacros(m) { + if (!m) return '—' + return `${Math.round(m.kcal || 0)} kcal · P ${Math.round(m.protein_g || 0)}g · F ${Math.round(m.fat_g || 0)}g · KH ${Math.round(m.carbs_g || 0)}g` +} + +function DayItems({ date, onChanged }) { const [items, setItems] = useState(null) + const [macros, setMacros] = useState(null) const [open, setOpen] = useState(false) const [error, setError] = useState(null) + const [applying, setApplying] = useState(false) + + const load = async () => { + const [nextItems, nextMacros] = await Promise.all([ + nutritionApi.listNutritionItems(date), + nutritionApi.getNutritionDayMacros(date).catch(() => null), + ]) + setItems(nextItems) + setMacros(nextMacros) + } const toggle = async () => { if (open) { setOpen(false) return } - if (!items) { - try { - setItems(await nutritionApi.listNutritionItems(date)) - } catch (e) { - setError(e.message) - return - } + try { + setError(null) + await load() + setOpen(true) + } catch (e) { + setError(e.message) + } + } + + const applyCatalog = async () => { + setApplying(true) + setError(null) + try { + const next = await nutritionApi.applyNutritionDayCatalogMacros(date) + setMacros(next) + onChanged?.() + } catch (e) { + setError(e.message) + } finally { + setApplying(false) } - setOpen(true) } return ( @@ -225,16 +263,54 @@ function DayItems({ date }) { {error &&
{error}
} {open && Array.isArray(items) && ( -