fix: Offene Lebensmittel nach Listen-Import wieder anzeigen
Some checks failed
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Failing after 2s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 27s

Unmapped ohne Kreuzjoin, Listen-GET kurz, Rebuild im Hintergrund.
Tandoor-Zugang (URL/Token) in den Einstellungen mit ausliefern.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-13 10:36:08 +02:00
parent fa542e0fb9
commit d6838abda7
23 changed files with 786 additions and 114 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("/")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) {
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{(r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
{r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
{r.source === 'manual' ? ' · selbst angelegt' : ''}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>

View File

@ -65,7 +65,7 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
<button key={r.id} type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} onClick={() => onPick(r.id)}>
<span>
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{(r.ingredients || []).length} Zutaten</span>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{r.ingredient_count ?? (r.ingredients || []).length} Zutaten</span>
</span>
</button>
))}
@ -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 {

View File

@ -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 (
<div className="card section-gap">
<div className="card-title">Tandoor (Familienrezepte)</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 14, lineHeight: 1.6 }}>
Persönlicher Zugang zu eurem Tandoor nicht systemweit. Gerichte bleiben dort; Mitai rechnet später Nährwerte.
Token unter Tandoor Einstellungen API-Tokens anlegen.
</p>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="tandoor-url">Adresse</label>
<input
id="tandoor-url"
className="form-input"
type="url"
placeholder="https://tandoor.stommer.de"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
autoComplete="off"
/>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="tandoor-token">API-Token</label>
<input
id="tandoor-token"
className="form-input"
type="password"
placeholder={tokenSet ? `Gespeichert (${tokenHint || '…'}) — leer lassen zum Behalten` : 'Token einfügen'}
value={token}
onChange={(e) => setToken(e.target.value)}
autoComplete="new-password"
/>
</div>
{lastOk && (
<p style={{ fontSize: 12, color: 'var(--accent-dark)', margin: '0 0 8px' }}>
Zuletzt erfolgreich: {String(lastOk).slice(0, 16).replace('T', ' ')}
</p>
)}
{lastError && !msg && (
<p style={{ fontSize: 12, color: 'var(--danger)', margin: '0 0 8px' }}>Letzter Fehler: {lastError}</p>
)}
{msg && (
<div style={{ fontSize: 13, color: msg.type === 'err' ? 'var(--danger)' : 'var(--accent-dark)', marginBottom: 10 }}>
{msg.text}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>Speichern</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={test}>Verbindung testen</button>
{tokenSet && (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={clear}>Zugang entfernen</button>
)}
</div>
</div>
)
}

View File

@ -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() {
</button>
</div>
<TandoorSettings />
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<LayoutDashboard size={15} color="var(--accent)" /> Startseite (Übersicht)

View File

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