From d6838abda7d81690cae0bc3ad5a664c2177a74ee Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 13 Sep 2026 10:36:08 +0200 Subject: [PATCH] fix: Offene Lebensmittel nach Listen-Import wieder anzeigen Unmapped ohne Kreuzjoin, Listen-GET kurz, Rebuild im Hintergrund. Tandoor-Zugang (URL/Token) in den Einstellungen mit ausliefern. Co-authored-by: Cursor --- .claude/docs/functional/BLS_FOOD_REFERENCE.md | 4 +- .claude/docs/technical/BLS_FOOD_REFERENCE.md | 8 +- .claude/rules/LESSONS_LEARNED.md | 8 + CLAUDE.md | 4 +- backend/data_layer/food_mapping.py | 85 +++++++++++ backend/data_layer/food_recipes.py | 65 ++++++-- backend/data_layer/nutrition_items.py | 23 +++ backend/data_layer/tandoor_client.py | 120 +++++++++++++++ backend/data_layer/tandoor_settings.py | 67 +++++++++ backend/main.py | 2 + .../067_profile_tandoor_settings.sql | 18 +++ .../068_nutrition_unmapped_indexes.sql | 19 +++ backend/routers/nutrition.py | 102 +++---------- backend/routers/tandoor.py | 78 ++++++++++ backend/tests/test_food_mapping.py | 64 ++++++++ backend/tests/test_tandoor_client.py | 40 +++++ backend/version.py | 6 +- docs/issues/issue-bls-food-mapping.md | 4 +- frontend/src/components/FoodRecipeEditor.jsx | 15 +- frontend/src/components/NutritionFoodMap.jsx | 17 ++- frontend/src/components/TandoorSettings.jsx | 140 ++++++++++++++++++ frontend/src/pages/SettingsPage.jsx | 3 + frontend/src/utils/api.js | 8 +- 23 files changed, 786 insertions(+), 114 deletions(-) create mode 100644 backend/data_layer/tandoor_client.py create mode 100644 backend/data_layer/tandoor_settings.py create mode 100644 backend/migrations/067_profile_tandoor_settings.sql create mode 100644 backend/migrations/068_nutrition_unmapped_indexes.sql create mode 100644 backend/routers/tandoor.py create mode 100644 backend/tests/test_tandoor_client.py create mode 100644 frontend/src/components/TandoorSettings.jsx diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 0d6ce3e..51209eb 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, **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“. +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“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund. ## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor) @@ -52,4 +52,4 @@ Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzl 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. +**Tandoor:** Familienrezepte bleiben dort. Persönlicher Zugang (URL + API-Token) unter Einstellungen — nicht systemweit; Token nur auf dem Server, nie im Export. Verbindungstest: `GET /api/user/` (Fallback Rezeptliste). Zutaten werden später live gelesen und auf BLS gemappt, kein Zutaten-Sync. Noch kein Rezeptimport; später `cooked_yield_g` und Katalogeintrag Fertiggericht pro 100 g. diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index 029e6cc..994ea31 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -1,6 +1,6 @@ # BLS Food Reference – technische Spec -**Stand:** 2026-09-12 · Migration **062** + **063** +**Stand:** 2026-09-12 · Migration **062**–**068** ## Tabellen @@ -36,9 +36,11 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - `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** 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. +- 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 (Modul `tandoor`): Migration **067** `profile_tandoor_settings`; `GET/PUT /api/tandoor/settings` (Token nur `token_set` / `token_hint`); `POST /api/tandoor/test` (10/min, kein Redirect-Follow). Client: `data_layer/tandoor_client.py`. Import und `cooked_yield_g` später. - Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g). -- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80) +- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `meta=true` liefert `{items, count, total, since_days}`. Listenzutaten-Datum: letzte `recipe_id`-Nutzung oder `food_recipes.updated_at`/`created_at` — kein Join über den Listennamen auf alle Tagebuchzeilen. Migration **068**: Partial-Index unmapped-open + `(profile_id, recipe_id, date)`. +- `GET /nutrition/recipes?brief=true` — Listen ohne Zutaten, nur `ingredient_count`. `GET /nutrition/recipes/{id}` lädt eine Liste. FDDB-Listenimport antwortet nach Upsert; `rebuild_daily_nutrients` im Hintergrund (`rebuild_pending`). - 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/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/.claude/rules/LESSONS_LEARNED.md b/.claude/rules/LESSONS_LEARNED.md index 1b01cfd..8dd69e7 100644 --- a/.claude/rules/LESSONS_LEARNED.md +++ b/.claude/rules/LESSONS_LEARNED.md @@ -176,6 +176,14 @@ known_limitations=( **Problem:** Versuch, `.claude/task/` Files zu committen **Lösung:** Nur `backend/` Code committen, `.claude/` ist local docs +## 9. Unmapped-Query: Kreuzjoin Liste × Tagebuch (12.09.2026) + +**Was:** Offene Lebensmittel über `food_recipes` mit `nutrition_items` per `recipe_id OR name_normalized` verbinden, um Zutaten ein Datum zu geben. +**Problem:** Nach FDDB-Listenimport Minuten bis Timeout; Zutaten ohne `last_date` fielen im 28-Tage-Filter weg — leere Offene-Liste. +**Lösung:** Nur Items mit `recipe_id` gruppieren; Fallback `recipe.updated_at`. Listen-GET `brief=true`. Rebuild nach Import im Hintergrund. + +**Regel:** Kein Join „Name der Liste gegen alle Tagebuchzeilen“. Datum aus der bereits gesetzten FK oder aus dem Eltern-Datensatz. + --- **Zusammenfassung Nutrition Cluster:** diff --git a/CLAUDE.md b/CLAUDE.md index a11c868..cffcbc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,11 +122,11 @@ 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/Kombinationen; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich. -- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. +- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**. - **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. -- **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}`. +- **Listen / Kombinationen (Mitai):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`. - **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index e76fb79..3911025 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -99,6 +99,91 @@ def filter_unmapped_since(rows: list[dict], since_days: int, today: date | None return out +def fetch_unmapped_rows(cur, profile_id: str) -> list[dict[str, Any]]: + """Diary lines without food/recipe plus list ingredients without mapping. + + Ingredient dates come from recipe.updated_at or last diary use of that list + (items that already have recipe_id) — not a join of every recipe name to + every diary row. + """ + cur.execute( + """ + SELECT source_name_normalized + FROM food_name_mappings + WHERE profile_id = %s + """, + (profile_id,), + ) + mapped = {r["source_name_normalized"] for r in cur.fetchall()} + cur.execute( + """ + SELECT i.source_name_raw, i.source_name_normalized, + COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date, + MIN(r.id::text) AS matching_recipe_id, + MIN(i.quantity_raw) AS sample_quantity_raw + FROM nutrition_items i + LEFT JOIN food_recipes r + ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized + WHERE i.profile_id = %s AND i.food_id IS NULL AND i.recipe_id IS NULL + GROUP BY i.source_name_raw, i.source_name_normalized + """, + (profile_id,), + ) + diary = [dict(r) | {"kind": "diary"} for r in cur.fetchall()] + cur.execute( + """ + SELECT i.source_name_raw, i.source_name_normalized, + COUNT(*) AS count, + MIN(COALESCE(u.last_used, r.updated_at::date, r.created_at::date)) AS first_date, + MAX(COALESCE(u.last_used, r.updated_at::date, r.created_at::date)) AS last_date, + MIN(i.quantity_raw) AS sample_quantity_raw + FROM food_recipe_ingredients i + JOIN food_recipes r ON r.id = i.recipe_id + LEFT JOIN ( + SELECT recipe_id, MIN(date) AS first_used, MAX(date) AS last_used + FROM nutrition_items + WHERE profile_id = %s AND recipe_id IS NOT NULL + GROUP BY recipe_id + ) u ON u.recipe_id = r.id + WHERE r.profile_id = %s + AND NOT EXISTS ( + SELECT 1 FROM food_name_mappings m + WHERE m.profile_id = r.profile_id + AND m.source_name_normalized = i.source_name_normalized + ) + GROUP BY i.source_name_raw, i.source_name_normalized + """, + (profile_id, profile_id), + ) + ings = [dict(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()] + out = [] + for row in merge_unmapped_rows(diary + ings): + key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw")) + if not key or key in mapped: + continue + row["last_date"] = as_iso_date(row.get("last_date")) + row["first_date"] = as_iso_date(row.get("first_date")) + out.append(row) + return out + + +def list_unmapped_payload( + cur, + profile_id: str, + since_days: int = 0, + count_only: bool = False, + meta: bool = False, +) -> Any: + days = max(0, min(int(since_days or 0), 3650)) + out = fetch_unmapped_rows(cur, profile_id) + items = sort_unmapped_rows(filter_unmapped_since(out, days) if days else out, days) + if count_only: + return {"count": len(items), "total": len(out), "since_days": days} + if meta: + return {"items": items, "count": len(items), "total": len(out), "since_days": days} + return items + + def sort_unmapped_rows(rows: list[dict], since_days: int = 0) -> list[dict]: rows = list(rows) if int(since_days or 0) > 0: diff --git a/backend/data_layer/food_recipes.py b/backend/data_layer/food_recipes.py index c7fdd8a..6d5cc13 100644 --- a/backend/data_layer/food_recipes.py +++ b/backend/data_layer/food_recipes.py @@ -69,7 +69,12 @@ def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[ if not inorm: continue raw_name = ing.get("source_name_raw") or inorm - qty = _resolved_ingredient_qty(cur, profile_id, raw_name, ing) + 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"), + ) cur.execute( """ INSERT INTO food_recipe_ingredients @@ -120,7 +125,7 @@ def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]: return cur.rowcount or 0, dates -def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]: +def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> list[dict[str, Any]]: cur.execute( """ SELECT id, name_raw, name_normalized, portions, description, source @@ -134,6 +139,33 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]: if not recipes: return [] ids = [str(r["id"]) for r in recipes] + if not with_ingredients: + cur.execute( + """ + SELECT recipe_id, COUNT(*) AS n + FROM food_recipe_ingredients + WHERE recipe_id = ANY(%s::uuid[]) + GROUP BY recipe_id + """, + (ids,), + ) + counts = {str(r["recipe_id"]): int(r["n"]) for r in cur.fetchall()} + for rec in recipes: + rec["id"] = str(rec["id"]) + rec["ingredient_count"] = counts.get(rec["id"], 0) + rec["ingredients"] = [] + return recipes + by_r = _ingredients_by_recipe(cur, ids) + for rec in recipes: + rec["id"] = str(rec["id"]) + rec["ingredients"] = by_r.get(rec["id"], []) + rec["ingredient_count"] = len(rec["ingredients"]) + return recipes + + +def _ingredients_by_recipe(cur, recipe_ids: list[str]) -> dict[str, list]: + if not recipe_ids: + return {} cur.execute( """ SELECT recipe_id, source_name_raw, source_name_normalized, @@ -142,22 +174,31 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]: WHERE recipe_id = ANY(%s::uuid[]) ORDER BY sort_order, source_name_raw """, - (ids,), + (recipe_ids,), ) - by_r: dict[str, list] = {str(i): [] for i in ids} + by_r: dict[str, list] = {str(i): [] for i in recipe_ids} for row in cur.fetchall(): by_r.setdefault(str(row["recipe_id"]), []).append(dict(row)) - for rec in recipes: - rec["id"] = str(rec["id"]) - rec["ingredients"] = by_r.get(rec["id"], []) - return recipes + return by_r 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 + cur.execute( + """ + SELECT id, name_raw, name_normalized, portions, description, source + FROM food_recipes + WHERE profile_id = %s AND id = %s + """, + (profile_id, recipe_id), + ) + rec = cur.fetchone() + if not rec: + return None + rec = dict(rec) + rec["id"] = str(rec["id"]) + rec["ingredients"] = _ingredients_by_recipe(cur, [rec["id"]]).get(rec["id"], []) + rec["ingredient_count"] = len(rec["ingredients"]) + return rec def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]: diff --git a/backend/data_layer/nutrition_items.py b/backend/data_layer/nutrition_items.py index c858e8f..46ec4f3 100644 --- a/backend/data_layer/nutrition_items.py +++ b/backend/data_layer/nutrition_items.py @@ -1,10 +1,14 @@ """Nutrition diary items, three macro sums, import policy, attribute resolve.""" from __future__ import annotations +import logging +import threading import uuid from datetime import date, datetime from typing import Any +logger = logging.getLogger(__name__) + from data_layer.food_mapping import ( get_food_mapping_with_cursor, normalize_food_name, @@ -222,6 +226,25 @@ def _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantit acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor) +def schedule_daily_nutrient_rebuild(profile_id: str, dates: list[str], context: str) -> None: + days = [str(d) for d in (dates or []) if d] + if not days: + return + + def _run() -> None: + try: + from db import get_cursor, get_db + + with get_db() as conn: + cur = get_cursor(conn) + for day in days: + rebuild_daily_nutrients(cur, profile_id, day) + except Exception: + logger.exception("Nährwert-Rebuild nach %s fehlgeschlagen", context) + + threading.Thread(target=_run, daemon=True, name="nutrition-rebuild").start() + + def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None: cur.execute( "DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s", diff --git a/backend/data_layer/tandoor_client.py b/backend/data_layer/tandoor_client.py new file mode 100644 index 0000000..f80118f --- /dev/null +++ b/backend/data_layer/tandoor_client.py @@ -0,0 +1,120 @@ +"""HTTP client for a user's Tandoor instance. Never logs the token.""" +from __future__ import annotations + +import json +import ssl +import urllib.error +import urllib.request +from typing import Any +from urllib.parse import urljoin, urlparse + + +class TandoorError(ValueError): + pass + + +def normalize_base_url(raw: str | None) -> str: + url = (raw or "").strip().rstrip("/") + if not url: + raise TandoorError("Tandoor-URL fehlt") + parsed = urlparse(url) + if parsed.scheme not in ("https", "http") or not parsed.netloc: + raise TandoorError("Tandoor-URL muss mit https:// oder http:// beginnen") + if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"): + raise TandoorError("Tandoor-URL muss https verwenden") + return url + + +def token_hint(token: str | None) -> str | None: + t = (token or "").strip() + if len(t) < 4: + return "gesetzt" if t else None + return f"…{t[-4:]}" + + +def public_settings(row: dict[str, Any] | None) -> dict[str, Any]: + if not row: + return { + "configured": False, + "base_url": "", + "token_set": False, + "token_hint": None, + "last_ok_at": None, + "last_error": None, + } + return { + "configured": True, + "base_url": row.get("base_url") or "", + "token_set": bool((row.get("api_token") or "").strip()), + "token_hint": token_hint(row.get("api_token")), + "last_ok_at": row.get("last_ok_at").isoformat() if row.get("last_ok_at") else None, + "last_error": row.get("last_error"), + } + + +def _request(base_url: str, token: str, path: str, timeout: float = 10.0) -> tuple[int, str, str]: + url = urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Token {token.strip()}", + "Accept": "application/json", + "User-Agent": "Mitai-Jinkendo/tandoor-connector", + }, + method="GET", + ) + ctx = ssl.create_default_context() + + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002 + return None + + opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=ctx)) + try: + with opener.open(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + ctype = resp.headers.get("Content-Type", "") + return resp.status, body, ctype + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") if e.fp else "" + ctype = e.headers.get("Content-Type", "") if e.headers else "" + return e.code, body, ctype + except TimeoutError as e: + raise TandoorError("Tandoor antwortet nicht (Zeitüberschreitung)") from e + except urllib.error.URLError as e: + raise TandoorError(f"Tandoor nicht erreichbar: {e.reason}") from e + + +def probe_connection(base_url: str, token: str) -> dict[str, Any]: + url = normalize_base_url(base_url) + tok = (token or "").strip() + if not tok: + raise TandoorError("API-Token fehlt") + status, body, ctype = _request(url, tok, "api/user/") + if status in (301, 302, 303, 307, 308) or "text/html" in (ctype or "") and status < 400: + raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen") + if status in (401, 403): + raise TandoorError("Token ungültig oder ohne Rechte") + if status == 404: + status, body, ctype = _request(url, tok, "api/recipe/?page=1&page_size=1") + if status in (401, 403): + raise TandoorError("Token ungültig oder ohne Rechte") + if status >= 400: + raise TandoorError(f"Tandoor antwortete mit HTTP {status}") + if "json" not in (ctype or "") and not (body or "").lstrip().startswith(("{", "[")): + raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen") + try: + data = json.loads(body) if body else {} + except json.JSONDecodeError as e: + raise TandoorError("Tandoor-Antwort war kein JSON") from e + name = None + if isinstance(data, dict): + results = data.get("results") + if isinstance(results, list) and results and isinstance(results[0], dict): + name = results[0].get("display_name") or results[0].get("username") or results[0].get("name") + else: + name = data.get("display_name") or data.get("username") or data.get("name") + count = data.get("count") + else: + count = None + return {"ok": True, "user": name, "recipe_count": count} diff --git a/backend/data_layer/tandoor_settings.py b/backend/data_layer/tandoor_settings.py new file mode 100644 index 0000000..da002fd --- /dev/null +++ b/backend/data_layer/tandoor_settings.py @@ -0,0 +1,67 @@ +"""Persist per-profile Tandoor credentials. Token never returned in full.""" +from __future__ import annotations + +from typing import Any + +from data_layer.tandoor_client import public_settings + + +def get_settings(cur, profile_id: str) -> dict[str, Any] | None: + cur.execute( + """ + SELECT profile_id, base_url, api_token, last_ok_at, last_error, updated_at + FROM profile_tandoor_settings + WHERE profile_id = %s + """, + (profile_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def get_public_settings(cur, profile_id: str) -> dict[str, Any]: + return public_settings(get_settings(cur, profile_id)) + + +def upsert_settings(cur, profile_id: str, base_url: str, token: str | None, *, clear_token: bool = False) -> dict[str, Any]: + existing = get_settings(cur, profile_id) + if clear_token: + cur.execute("DELETE FROM profile_tandoor_settings WHERE profile_id = %s", (profile_id,)) + return public_settings(None) + stored = (token or "").strip() or ((existing or {}).get("api_token") or "") + if not stored: + raise ValueError("API-Token fehlt") + cur.execute( + """ + INSERT INTO profile_tandoor_settings (profile_id, base_url, api_token, last_error, updated_at) + VALUES (%s, %s, %s, NULL, NOW()) + ON CONFLICT (profile_id) DO UPDATE SET + base_url = EXCLUDED.base_url, + api_token = EXCLUDED.api_token, + last_error = NULL, + updated_at = NOW() + """, + (profile_id, base_url, stored), + ) + return get_public_settings(cur, profile_id) + + +def record_probe(cur, profile_id: str, ok: bool, error: str | None = None) -> None: + if ok: + cur.execute( + """ + UPDATE profile_tandoor_settings + SET last_ok_at = NOW(), last_error = NULL, updated_at = NOW() + WHERE profile_id = %s + """, + (profile_id,), + ) + else: + cur.execute( + """ + UPDATE profile_tandoor_settings + SET last_error = %s, updated_at = NOW() + WHERE profile_id = %s + """, + ((error or "")[:300], profile_id), + ) diff --git a/backend/main.py b/backend/main.py index af22ea2..d75d7a8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -39,6 +39,7 @@ from routers import reports # Strukturierter PDF-Bericht (Profil v1) from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics from routers import bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping +from routers import tandoor # per-user Tandoor connection # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -137,6 +138,7 @@ app.include_router(admin_activity_attribute_profiles.router) # /api/admin/train app.include_router(bls.router) # /api/bls/* app.include_router(admin_bls.router) # /api/admin/bls/* app.include_router(admin_food_mappings.router) # /api/admin/food-mappings +app.include_router(tandoor.router) # /api/tandoor/* # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/067_profile_tandoor_settings.sql b/backend/migrations/067_profile_tandoor_settings.sql new file mode 100644 index 0000000..90cc00b --- /dev/null +++ b/backend/migrations/067_profile_tandoor_settings.sql @@ -0,0 +1,18 @@ +-- Per-user Tandoor connection (URL + API token). Not a system-wide connector. + +CREATE TABLE IF NOT EXISTS profile_tandoor_settings ( + profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + base_url VARCHAR(300) NOT NULL, + api_token TEXT NOT NULL, + last_ok_at TIMESTAMPTZ, + last_error TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE profile_tandoor_settings IS 'Persönliche Tandoor-API (Familie); Token nie systemweit'; +COMMENT ON COLUMN profile_tandoor_settings.api_token IS 'Tandoor Access Token; nur Backend, nie im JSON-Export'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 067: profile_tandoor_settings'; +END $$; diff --git a/backend/migrations/068_nutrition_unmapped_indexes.sql b/backend/migrations/068_nutrition_unmapped_indexes.sql new file mode 100644 index 0000000..a6fd31c --- /dev/null +++ b/backend/migrations/068_nutrition_unmapped_indexes.sql @@ -0,0 +1,19 @@ +-- Migration 068: schnellere Unmapped-Liste und Listen-Nutzung + +CREATE INDEX IF NOT EXISTS idx_nutrition_items_unmapped_open + ON nutrition_items (profile_id, source_name_normalized) + WHERE food_id IS NULL AND recipe_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_nutrition_items_profile_recipe_date + ON nutrition_items (profile_id, recipe_id, date) + WHERE recipe_id IS NOT NULL; + +COMMENT ON INDEX idx_nutrition_items_unmapped_open IS + 'Tagebuchzeilen ohne Lebensmittel und ohne Liste'; +COMMENT ON INDEX idx_nutrition_items_profile_recipe_date IS + 'Letzte Nutzung einer Liste ohne Join über den Listennamen'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 068: nutrition unmapped/recipe indexes'; +END $$; diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 18d9d2f..7ff2c26 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -366,96 +366,42 @@ def list_nutrition_items( def list_unmapped_foods( since_days: int = 0, count_only: bool = False, + meta: bool = False, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): - from data_layer.food_mapping import ( - as_iso_date, - filter_unmapped_since, - merge_unmapped_rows, - normalize_food_name, - sort_unmapped_rows, - ) + from data_layer.food_mapping import list_unmapped_payload pid = x_profile_id or session["profile_id"] with get_db() as conn: - cur = get_cursor(conn) - cur.execute( - """ - SELECT source_name_normalized - FROM food_name_mappings - WHERE profile_id = %s - """, - (pid,), - ) - mapped = {r["source_name_normalized"] for r in cur.fetchall()} - cur.execute( - """ - SELECT i.source_name_raw, i.source_name_normalized, - COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date, - MIN(r.id::text) AS matching_recipe_id, - MIN(i.quantity_raw) AS sample_quantity_raw - FROM nutrition_items i - LEFT JOIN food_recipes r - ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized - WHERE i.profile_id=%s AND i.food_id IS NULL AND i.recipe_id IS NULL - GROUP BY i.source_name_raw, i.source_name_normalized - ORDER BY count DESC, i.source_name_normalized - """, - (pid,), - ) - diary = [r2d(r) | {"kind": "diary"} for r in cur.fetchall()] - cur.execute( - """ - SELECT i.source_name_raw, i.source_name_normalized, - COUNT(*) AS count, u.first_used AS first_date, u.last_used AS last_date, - MIN(i.quantity_raw) AS sample_quantity_raw - FROM food_recipe_ingredients i - JOIN food_recipes r ON r.id = i.recipe_id - LEFT JOIN food_name_mappings m - ON m.profile_id = r.profile_id - AND m.source_name_normalized = i.source_name_normalized - LEFT JOIN ( - SELECT r2.id AS recipe_id, MIN(ni.date) AS first_used, MAX(ni.date) AS last_used - FROM food_recipes r2 - JOIN nutrition_items ni - ON ni.profile_id = r2.profile_id - AND (ni.recipe_id = r2.id OR ni.source_name_normalized = r2.name_normalized) - WHERE r2.profile_id = %s - GROUP BY r2.id - ) u ON u.recipe_id = r.id - WHERE r.profile_id = %s AND m.id IS NULL - GROUP BY i.source_name_raw, i.source_name_normalized, u.first_used, u.last_used - ORDER BY count DESC, i.source_name_normalized - """, - (pid, pid), - ) - ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()] - merged = merge_unmapped_rows(diary + ings) - out = [] - for row in merged: - key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw")) - if key in mapped: - continue - row["last_date"] = as_iso_date(row.get("last_date")) - row["first_date"] = as_iso_date(row.get("first_date")) - out.append(row) - days = max(0, min(int(since_days or 0), 3650)) - recent = sort_unmapped_rows(filter_unmapped_since(out, days), days) - if count_only: - return {"count": len(recent), "total": len(out), "since_days": days} - return recent if days else sort_unmapped_rows(out, 0) + return list_unmapped_payload(get_cursor(conn), pid, since_days, count_only, meta) @router.get("/recipes") def list_food_recipes( + brief: bool = False, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): from data_layer.food_recipes import list_recipes pid = get_pid(x_profile_id) with get_db() as conn: - return list_recipes(get_cursor(conn), pid) + return list_recipes(get_cursor(conn), pid, with_ingredients=not brief) + + +@router.get("/recipes/{recipe_id}") +def get_food_recipe( + recipe_id: str, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_recipes import get_recipe + pid = get_pid(x_profile_id) + with get_db() as conn: + rec = get_recipe(get_cursor(conn), pid, recipe_id) + if not rec: + raise HTTPException(404, "Rezept nicht gefunden") + return rec @router.post("/recipes") @@ -530,10 +476,10 @@ async def import_fddb_lists( with get_db() as conn: cur = get_cursor(conn) stats = upsert_recipes(cur, pid, recipes) - from data_layer.nutrition_items import rebuild_daily_nutrients - for d in stats.pop("dates_linked", []) or []: - rebuild_daily_nutrients(cur, pid, d) - return {"ok": True, "recipes": len(recipes), **stats} + dates = stats.pop("dates_linked", []) or [] + from data_layer.nutrition_items import schedule_daily_nutrient_rebuild + schedule_daily_nutrient_rebuild(pid, dates, "FDDB-Listenimport") + return {"ok": True, "recipes": len(recipes), "rebuild_pending": len(dates), **stats} @router.post("/recipes/{recipe_id}/apply") diff --git a/backend/routers/tandoor.py b/backend/routers/tandoor.py new file mode 100644 index 0000000..5cc9d0a --- /dev/null +++ b/backend/routers/tandoor.py @@ -0,0 +1,78 @@ +"""Per-user Tandoor connection. No recipe import yet.""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from starlette.requests import Request +from slowapi import Limiter +from slowapi.util import get_remote_address + +from auth import require_auth +from data_layer.tandoor_client import TandoorError, normalize_base_url, probe_connection +from data_layer.tandoor_settings import ( + get_public_settings, + get_settings, + record_probe, + upsert_settings, +) +from db import get_cursor, get_db + +router = APIRouter(prefix="/api/tandoor", tags=["tandoor"]) +limiter = Limiter(key_func=get_remote_address) + + +class TandoorSettingsIn(BaseModel): + base_url: str = "" + token: Optional[str] = None + clear_token: bool = False + + +class TandoorProbeIn(BaseModel): + base_url: Optional[str] = None + token: Optional[str] = Field(default=None, description="Nur zum Testen; leer = gespeichertes Token") + + +@router.get("/settings") +def read_settings(session: dict = Depends(require_auth)): + pid = session["profile_id"] + with get_db() as conn: + return get_public_settings(get_cursor(conn), pid) + + +@router.put("/settings") +def write_settings(body: TandoorSettingsIn, session: dict = Depends(require_auth)): + pid = session["profile_id"] + try: + if body.clear_token: + url = "" + else: + url = normalize_base_url(body.base_url) + with get_db() as conn: + return upsert_settings( + get_cursor(conn), pid, url, body.token, clear_token=body.clear_token + ) + except (TandoorError, ValueError) as e: + raise HTTPException(400, str(e)) from e + + +@router.post("/test") +@limiter.limit("10/minute") +def test_connection(request: Request, body: TandoorProbeIn | None = None, session: dict = Depends(require_auth)): + pid = session["profile_id"] + body = body or TandoorProbeIn() + with get_db() as conn: + cur = get_cursor(conn) + stored = get_settings(cur, pid) + url = (body.base_url or "").strip() or ((stored or {}).get("base_url") or "") + token = (body.token or "").strip() or ((stored or {}).get("api_token") or "") + try: + result = probe_connection(url, token) + if stored: + record_probe(cur, pid, True) + return result + except TandoorError as e: + if stored: + record_probe(cur, pid, False, str(e)) + raise HTTPException(400, str(e)) from e diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index 75377d4..a3d7b8c 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -2,8 +2,10 @@ from datetime import date from csv_parser.executor import guess_nutrition_item_fields from data_layer.food_mapping import ( + fetch_unmapped_rows, filter_unmapped_since, list_quantity_units, + list_unmapped_payload, merge_unmapped_rows, normalize_food_name, parse_quantity, @@ -84,6 +86,68 @@ def test_recent_window_drops_old_and_dateless_foods(): assert len(filter_unmapped_since(rows, 0, today=today)) == 3 +class _FakeCur: + def __init__(self, batches): + self.batches = list(batches) + self._i = 0 + self._rows = [] + + def execute(self, sql, params=None): + self._rows = self.batches[self._i] + self._i += 1 + + def fetchall(self): + return self._rows + + +def test_fetch_unmapped_keeps_ingredient_dates_and_skips_mapped(): + cur = _FakeCur([ + [{"source_name_normalized": "haferflocken"}], + [], + [ + { + "source_name_raw": "Salz", + "source_name_normalized": "salz", + "count": 2, + "first_date": date(2026, 9, 10), + "last_date": date(2026, 9, 11), + "sample_quantity_raw": "1 g", + }, + { + "source_name_raw": "Haferflocken", + "source_name_normalized": "haferflocken", + "count": 1, + "first_date": date(2026, 9, 1), + "last_date": date(2026, 9, 8), + "sample_quantity_raw": "40 g", + }, + ], + ]) + rows = fetch_unmapped_rows(cur, "p") + assert [r["source_name_normalized"] for r in rows] == ["salz"] + assert rows[0]["last_date"] == "2026-09-11" + assert rows[0]["kind"] == "recipe_ingredient" + + +def test_unmapped_meta_includes_recent_ingredients(): + batches = [ + [], + [], + [{ + "source_name_raw": "Salz", + "source_name_normalized": "salz", + "count": 1, + "first_date": date(2026, 9, 10), + "last_date": date(2026, 9, 11), + "sample_quantity_raw": "1 g", + }], + ] + payload = list_unmapped_payload(_FakeCur(batches), "p", since_days=0, meta=True) + assert payload["total"] == 1 + assert payload["count"] == 1 + assert payload["items"][0]["source_name_normalized"] == "salz" + + def test_macros_differ_rounds(): assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0}) assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0}) diff --git a/backend/tests/test_tandoor_client.py b/backend/tests/test_tandoor_client.py new file mode 100644 index 0000000..9d469b9 --- /dev/null +++ b/backend/tests/test_tandoor_client.py @@ -0,0 +1,40 @@ +from data_layer.tandoor_client import TandoorError, normalize_base_url, public_settings, token_hint + + +def test_normalize_https_strips_slash(): + assert normalize_base_url("https://tandoor.stommer.de/") == "https://tandoor.stommer.de" + + +def test_normalize_rejects_empty_and_bare_host(): + try: + normalize_base_url(" ") + except TandoorError as e: + assert "fehlt" in str(e) + else: + raise AssertionError("expected TandoorError") + try: + normalize_base_url("tandoor.stommer.de") + except TandoorError: + pass + else: + raise AssertionError("expected TandoorError") + + +def test_normalize_rejects_public_http(): + try: + normalize_base_url("http://tandoor.stommer.de") + except TandoorError as e: + assert "https" in str(e) + else: + raise AssertionError("expected TandoorError") + + +def test_token_hint_never_full(): + assert token_hint("abcdefghijklmnop") == "…mnop" + assert "abcd" not in (token_hint("secret-token-value") or "") + assert public_settings(None)["token_set"] is False + pub = public_settings({"base_url": "https://tandoor.example", "api_token": "super-secret-token"}) + assert pub["token_set"] is True + assert pub["token_hint"] == "…oken" + assert "super" not in (pub["token_hint"] or "") + assert "api_token" not in pub diff --git a/backend/version.py b/backend/version.py index 1e174e2..d47d686 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 = "20260912c" # 066 recipe ingredient units +DB_SCHEMA_VERSION = "20260912e" # 068 nutrition unmapped/recipe indexes MODULE_VERSIONS = { "auth": "1.2.0", @@ -20,7 +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.3.2", # UI: Liste/Kombination; Tandoor-Gerichte später + "nutrition": "1.3.3", # Unmapped ohne Kreuzjoin; Listen-Import Rebuild async + "tandoor": "1.0.0", # Per-user URL + Token + Verbindungstest "bls": "1.0.5", # GET /bls/units "photos": "1.0.0", "insights": "1.3.0", @@ -58,6 +59,7 @@ CHANGELOG = [ "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", + "Tandoor: persönlicher Zugang (URL + Token) in Einstellungen, Verbindungstest, kein Import", ], }, { diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md index 8194c63..db518bf 100644 --- a/docs/issues/issue-bls-food-mapping.md +++ b/docs/issues/issue-bls-food-mapping.md @@ -21,4 +21,6 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende - 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 +- Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import) +- Folge: Tandoor-Gerichte live lesen + BLS-Mapping + `cooked_yield_g` — kein Zutaten-Vollsync +- Fix 2026-09-12: Offene Liste war leer, weil der 28-Tage-Filter Zutaten ohne `last_date` verwarf und die Unmapped-Query Listen×Tagebuch kreuzte (Minuten). Import wartete auf synchrone Tages-Rebuilds. diff --git a/frontend/src/components/FoodRecipeEditor.jsx b/frontend/src/components/FoodRecipeEditor.jsx index 18a3d7b..5b3a8f4 100644 --- a/frontend/src/components/FoodRecipeEditor.jsx +++ b/frontend/src/components/FoodRecipeEditor.jsx @@ -13,10 +13,17 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) { setOpen(true) } - const startEdit = (r) => { - setEditing(r) + const startEdit = async (r) => { setError(null) - setOpen(true) + try { + const full = (r.ingredients && r.ingredients.length) || r.ingredient_count === 0 + ? r + : await api.getNutritionRecipe(r.id) + setEditing(full) + setOpen(true) + } catch (e) { + setError(e.message) + } } const save = async (body) => { @@ -53,7 +60,7 @@ export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
{r.name_raw}
- {(r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen + {r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen {r.source === 'manual' ? ' · selbst angelegt' : ''}
diff --git a/frontend/src/components/NutritionFoodMap.jsx b/frontend/src/components/NutritionFoodMap.jsx index 01b8970..9b06741 100644 --- a/frontend/src/components/NutritionFoodMap.jsx +++ b/frontend/src/components/NutritionFoodMap.jsx @@ -65,7 +65,7 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) { ))} @@ -149,16 +149,16 @@ export default function NutritionFoodMap({ onChanged, onMapped }) { const load = async () => { const gen = ++loadGen.current try { - const [u, m, r, counts] = await Promise.all([ - api.listUnmappedFoods(sinceDays), + const [u, m, r] = await Promise.all([ + api.listUnmappedFoods(sinceDays, true), api.listMyFoodMappings(), - api.listNutritionRecipes().catch(() => []), - api.listUnmappedFoodCount(sinceDays).catch(() => null), + api.listNutritionRecipes(true).catch(() => []), ]) if (gen !== loadGen.current) return askedSuggest.current = new Set() - setUnmapped(Array.isArray(u) ? u : []) - setTotalOpen(Number(counts?.total) || (Array.isArray(u) ? u.length : 0)) + const items = Array.isArray(u) ? u : (u?.items || []) + setUnmapped(items) + setTotalOpen(Number(u?.total) || items.length) setLearned(m) setRecipes(Array.isArray(r) ? r : []) } catch (e) { @@ -340,7 +340,8 @@ export default function NutritionFoodMap({ onChanged, onMapped }) { const res = await api.importFddbLists(file) await load() onChanged?.() - setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.`) + const pending = res.rebuild_pending ? ' Tageswerte werden im Hintergrund nachgerechnet.' : '' + setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.${pending}`) } catch (e) { setError(e.message) } finally { diff --git a/frontend/src/components/TandoorSettings.jsx b/frontend/src/components/TandoorSettings.jsx new file mode 100644 index 0000000..1d79461 --- /dev/null +++ b/frontend/src/components/TandoorSettings.jsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react' +import { api } from '../utils/api' + +export default function TandoorSettings() { + const [baseUrl, setBaseUrl] = useState('') + const [token, setToken] = useState('') + const [tokenSet, setTokenSet] = useState(false) + const [tokenHint, setTokenHint] = useState(null) + const [lastOk, setLastOk] = useState(null) + const [lastError, setLastError] = useState(null) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState(null) + + const load = async () => { + const s = await api.getTandoorSettings() + setBaseUrl(s.base_url || '') + setTokenSet(Boolean(s.token_set)) + setTokenHint(s.token_hint || null) + setLastOk(s.last_ok_at || null) + setLastError(s.last_error || null) + setToken('') + } + + useEffect(() => { + load().catch((e) => setMsg({ type: 'err', text: e.message })) + }, []) + + const save = async () => { + setBusy(true) + setMsg(null) + try { + const s = await api.putTandoorSettings({ + base_url: baseUrl, + token: token.trim() || undefined, + }) + setToken('') + setTokenSet(Boolean(s.token_set)) + setTokenHint(s.token_hint || null) + setLastError(s.last_error || null) + setMsg({ type: 'ok', text: 'Gespeichert. Das Token bleibt nur auf dem Server.' }) + } catch (e) { + setMsg({ type: 'err', text: e.message }) + } finally { + setBusy(false) + } + } + + const test = async () => { + setBusy(true) + setMsg(null) + try { + const res = await api.testTandoorConnection({ + base_url: baseUrl || undefined, + token: token.trim() || undefined, + }) + const who = res.user ? ` als ${res.user}` : '' + setMsg({ type: 'ok', text: `Verbindung ok${who}.` }) + setLastError(null) + setLastOk(new Date().toISOString()) + } catch (e) { + setMsg({ type: 'err', text: e.message }) + } finally { + setBusy(false) + } + } + + const clear = async () => { + if (!confirm('Tandoor-Zugang wirklich entfernen?')) return + setBusy(true) + setMsg(null) + try { + await api.putTandoorSettings({ base_url: '', clear_token: true }) + setBaseUrl('') + setToken('') + setTokenSet(false) + setTokenHint(null) + setLastOk(null) + setLastError(null) + setMsg({ type: 'ok', text: 'Zugang entfernt.' }) + } catch (e) { + setMsg({ type: 'err', text: e.message }) + } finally { + setBusy(false) + } + } + + return ( +
+
Tandoor (Familienrezepte)
+

+ Persönlicher Zugang zu eurem Tandoor — nicht systemweit. Gerichte bleiben dort; Mitai rechnet später Nährwerte. + Token unter Tandoor → Einstellungen → API-Tokens anlegen. +

+
+ + setBaseUrl(e.target.value)} + autoComplete="off" + /> +
+
+ + setToken(e.target.value)} + autoComplete="new-password" + /> +
+ {lastOk && ( +

+ Zuletzt erfolgreich: {String(lastOk).slice(0, 16).replace('T', ' ')} +

+ )} + {lastError && !msg && ( +

Letzter Fehler: {lastError}

+ )} + {msg && ( +
+ {msg.text} +
+ )} +
+ + + {tokenSet && ( + + )} +
+
+ ) +} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index e2e2d06..0cdce41 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -6,6 +6,7 @@ import { useAuth } from '../context/AuthContext' import { Avatar } from './ProfileSelect' import { api } from '../utils/api' import FeatureUsageOverview from '../components/FeatureUsageOverview' +import TandoorSettings from '../components/TandoorSettings' import UsageBadge from '../components/UsageBadge' const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780'] @@ -428,6 +429,8 @@ export default function SettingsPage() {
+ +
Startseite (Übersicht) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0aed029..760cf77 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -108,6 +108,9 @@ export const api = { deleteProfile: (id) => req(`/profiles/${id}`, {method:'DELETE'}), getProfile: () => req('/profile'), updateActiveProfile:(d)=> req('/profile', jput(d)), + getTandoorSettings: () => req('/tandoor/settings'), + putTandoorSettings: (d) => req('/tandoor/settings', jput(d)), + testTandoorConnection: (d) => req('/tandoor/test', json(d || {})), // App-Bereich: konfigurierbares Dashboard (Layout JSON) + Widget-Katalog getAppWidgetsCatalog: () => req('/app/widgets/catalog'), @@ -240,9 +243,10 @@ export const api = { const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d }, listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'), - listUnmappedFoods: (sinceDays=0) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}`), + listUnmappedFoods: (sinceDays=0, meta=false) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}${meta ? '&meta=true' : ''}`), listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`), - listNutritionRecipes: () => req('/nutrition/recipes'), + listNutritionRecipes: (brief=false) => req(`/nutrition/recipes${brief ? '?brief=true' : ''}`), + getNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`), createNutritionRecipe: (d) => req('/nutrition/recipes', json(d)), updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)), deleteNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`, {method:'DELETE'}),