feat: Rezepte beim Zuordnen anlegen und offene Zutaten listen
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 29s

Lebensmittel oder Rezept wählbar; gemappte Zutaten werden übernommen, offene erscheinen in der Liste. Dazu Fettgehalt-Suche, EPA-Stoffe, Rezept-CRUD und Wechsel bestehender Zuordnungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-12 16:59:24 +02:00
parent 132a364a3a
commit a7e5511039
26 changed files with 1234 additions and 147 deletions

View File

@ -8,7 +8,7 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte
## Zuordnung (UX) ## Zuordnung (UX)
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer (z. B. Salz), **Neu anlegen** in derselben Zeile. Zusätzlich Popup-Suche. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit, nicht über alle offenen Namen auf einmal. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“ und müssen nicht gemappt werden. Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Rezept** in derselben Zeile (oder im Such-Popup). Ein Lebensmittel wird ein Katalogeintrag. Ein Rezept bekommt Zutaten (Name + Gramm): schon zugeordnete Namen werden übernommen, offene Zutaten erscheinen danach in der Offene-Liste (Kennzeichnung „Rezeptzutat“) und lassen sich dort nur als Lebensmittel zuordnen. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit, nicht über alle offenen Namen auf einmal. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“ und müssen nicht gemappt werden.
## FDDB-Listen / eigene Rezepte ## FDDB-Listen / eigene Rezepte
@ -26,6 +26,12 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import. - Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten. - Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
## Manuelle Lebensmittel / Supplemente
Eigene Einträge (z. B. Norsan Omega-3 + EPA) speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Admin → Stoffe & Attribute.
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.
## Später ## Später
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge). Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).

View File

@ -28,13 +28,15 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
## Router ## Router
- `/api/bls/*` — Suche, eigene Foods, eigene Mappings - `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings
- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat
- `/api/admin/bls/*` — Import, Katalog, Attribute - `/api/admin/bls/*` — Import, Katalog, Attribute
- `/api/admin/food-mappings` — Admin-CRUD - `/api/admin/food-mappings` — Admin-CRUD
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve - `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
- `GET /api/nutrition/recipes`, `POST /api/nutrition/recipes/import-fddb-lists`, `POST /api/nutrition/recipes/{id}/apply` - `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
- `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping - Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce) - Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). **Neu anlegen** fragt zuerst Lebensmittel vs. Rezept; Rezept-Zutaten ohne Mapping erscheinen als `kind=recipe_ingredient` in Unmapped.
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80) - `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80)
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar) - 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**) - `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)

View File

@ -124,6 +124,9 @@ frontend/src/
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich. - **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. - **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite.
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“. - **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g.
- **Rezepte:** Nutzer-CRUD `POST/PUT/DELETE /nutrition/recipes`. Beim Zuordnen zuerst **Lebensmittel oder Rezept**; Rezept legt Zutaten an — bereits gemappte werden übernommen, offene erscheinen in der Liste. Admin-Mappings: `PUT /admin/food-mappings/{id}` zum direkten Wechsel.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **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. - **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

@ -0,0 +1,128 @@
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
from __future__ import annotations
from typing import Any
MACRO_TO_KEY = {
"kcal": "ENERCC",
"protein_g": "PROT625",
"fat_g": "FAT",
"carbs_g": "CHO",
}
def scale_to_per_100g(value: float, serving_g: float | None) -> float:
if not serving_g or serving_g <= 0 or serving_g == 100:
return float(value)
return float(value) * (100.0 / float(serving_g))
def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]:
q = (query or "").strip()
lim = min(max(int(limit or 40), 1), 80)
if q:
like = f"%{q}%"
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
AND (
attr_key ILIKE %s OR name_de ILIKE %s
OR COALESCE(name_en, '') ILIKE %s
)
ORDER BY
CASE WHEN attr_key ILIKE %s THEN 0
WHEN name_de ILIKE %s THEN 1
ELSE 2 END,
sort_order, attr_key
LIMIT %s
""",
(like, like, like, q, f"{q}%", lim),
)
else:
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
ORDER BY sort_order, attr_key
LIMIT %s
""",
(lim,),
)
return [dict(r) for r in cur.fetchall()]
def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
if not values:
return 0
written = 0
for raw_key, raw_val in values.items():
key = str(raw_key or "").strip()
if not key or raw_val is None or raw_val == "":
continue
try:
num = float(raw_val)
except (TypeError, ValueError):
continue
cur.execute(
"""
SELECT id FROM food_attributes
WHERE attr_key = %s AND is_active = true AND data_type = 'num_per_100g'
""",
(key,),
)
row = cur.fetchone()
if not row:
continue
cur.execute(
"""
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
VALUES (%s, %s, %s, false)
ON CONFLICT (food_id, attribute_id)
DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
""",
(food_id, row["id"], num),
)
written += 1
return written
def macros_and_attributes_to_values(
macros: dict[str, Any] | None,
attributes: dict[str, Any] | None,
serving_g: float | None = None,
) -> dict[str, float]:
out: dict[str, float] = {}
for field, key in MACRO_TO_KEY.items():
if not macros or field not in macros or macros[field] is None or macros[field] == "":
continue
try:
out[key] = scale_to_per_100g(float(macros[field]), serving_g)
except (TypeError, ValueError):
continue
for key, raw in (attributes or {}).items():
if raw is None or raw == "":
continue
try:
out[str(key)] = scale_to_per_100g(float(raw), serving_g)
except (TypeError, ValueError):
continue
return out
def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
cur.execute(
"""
SELECT a.attr_key, v.value_num
FROM food_attribute_values v
JOIN food_attributes a ON a.id = v.attribute_id
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
AND v.value_num IS NOT NULL AND v.is_trace = false
AND NOT (a.attr_key = ANY(%s))
ORDER BY a.sort_order, a.attr_key
""",
(food_id, list(MACRO_TO_KEY.values())),
)
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}

View File

@ -22,13 +22,19 @@ BUNDLE_VERSION = 1
def _jsonable(value: Any) -> Any: def _jsonable(value: Any) -> Any:
if value is None: if value is None:
return None return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Decimal): if isinstance(value, Decimal):
return float(value) return float(value)
if isinstance(value, UUID): if isinstance(value, UUID):
return str(value) return str(value)
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value]
if hasattr(value, "isoformat"): if hasattr(value, "isoformat"):
return value.isoformat() return value.isoformat()
return value return str(value)
def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]: def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]:
@ -54,7 +60,7 @@ def portable_mapping(row: dict[str, Any]) -> dict[str, Any]:
"food_name_de": row.get("food_name_de") or row.get("name_de"), "food_name_de": row.get("food_name_de") or row.get("name_de"),
"catalog_kind": row.get("catalog_kind"), "catalog_kind": row.get("catalog_kind"),
"external_key": row.get("external_key"), "external_key": row.get("external_key"),
"grams_per_unit": row.get("grams_per_unit"), "grams_per_unit": _jsonable(row.get("grams_per_unit")),
"source_unit": row.get("source_unit"), "source_unit": row.get("source_unit"),
} }
@ -126,12 +132,14 @@ def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]:
manuals = [] manuals = []
for food in cur.fetchall(): for food in cur.fetchall():
macros = catalog_macros_for_item(cur, food["id"], 100.0) macros = catalog_macros_for_item(cur, food["id"], 100.0)
from data_layer.food_attributes import extra_attributes_for_food
manuals.append({ manuals.append({
"name_de": food["name_de"], "name_de": food["name_de"],
"name_en": food.get("name_en"), "name_en": food.get("name_en"),
"catalog_kind": food["catalog_kind"], "catalog_kind": food["catalog_kind"],
"external_key": food.get("external_key"), "external_key": food.get("external_key"),
"macros_per_100g": macros, "macros_per_100g": macros,
"attributes": extra_attributes_for_food(cur, food["id"]),
}) })
cur.execute( cur.execute(
""" """
@ -165,14 +173,14 @@ def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]:
for ing in rec.get("ingredients") or [] for ing in rec.get("ingredients") or []
], ],
}) })
return { return _jsonable({
"format": BUNDLE_FORMAT, "format": BUNDLE_FORMAT,
"version": BUNDLE_VERSION, "version": BUNDLE_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(), "exported_at": datetime.now(timezone.utc).isoformat(),
"manual_foods": manuals, "manual_foods": manuals,
"mappings": mappings, "mappings": mappings,
"recipes": recipes, "recipes": recipes,
} })
def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None: def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None:
@ -180,9 +188,10 @@ def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | Non
if not name: if not name:
return None return None
existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"}) existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"})
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
values = macros_and_attributes_to_values(food.get("macros_per_100g"), food.get("attributes"))
if existing: if existing:
from routers.admin_bls import _write_manual_macros write_numeric_attributes(cur, existing, values)
_write_manual_macros(cur, existing, food.get("macros_per_100g"))
return existing return existing
key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}" key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}"
cur.execute( cur.execute(
@ -195,8 +204,7 @@ def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | Non
(name, food.get("name_en"), profile_id, key), (name, food.get("name_en"), profile_id, key),
) )
food_id = str(cur.fetchone()["id"]) food_id = str(cur.fetchone()["id"])
from routers.admin_bls import _write_manual_macros write_numeric_attributes(cur, food_id, values)
_write_manual_macros(cur, food_id, food.get("macros_per_100g"))
return food_id return food_id

View File

@ -31,6 +31,17 @@ def normalize_food_name(raw: str | None) -> str:
return s.lower() return s.lower()
_LIST_COMMA_RE = re.compile(r",(?!\s*\d)")
def primary_search_query(query: str | None) -> str:
"""Use the name before a list-comma, but keep decimal commas (9,5 %)."""
q = (query or "").strip()
if not q:
return ""
return _LIST_COMMA_RE.split(q, maxsplit=1)[0].strip()
def merge_unmapped_rows(rows: list[dict]) -> list[dict]: def merge_unmapped_rows(rows: list[dict]) -> list[dict]:
merged: dict[str, dict] = {} merged: dict[str, dict] = {}
for row in rows: for row in rows:
@ -359,17 +370,50 @@ def _value_origin_for_food(cur, food_id: str) -> str:
return "manual_catalog" return "manual_catalog"
_SEARCH_NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
def _catalog_must_tokens(query: str) -> list[list[str]]:
"""AND-groups for catalog search: first word plus each number (with 9,5/10 aliases)."""
text = normalize_food_name(primary_search_query(query))
nums = [m.replace(",", ".") for m in _SEARCH_NUM_RE.findall(text)]
words = [t for t in re.split(r"[^a-z0-9äöüß]+", _SEARCH_NUM_RE.sub(" ", text)) if len(t) >= 2]
groups: list[list[str]] = []
if words:
groups.append([words[0]])
for num in nums[:3]:
variants = {num, num.replace(".", ",")}
try:
value = float(num)
except ValueError:
value = None
if value is not None and (abs(value - 10) <= 0.6 or abs(value - 9.5) <= 0.6):
variants.update({"10", "9.5", "9,5"})
groups.append(list(variants))
return groups
def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]: def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
q = (query or "").strip() q = (query or "").strip()
if not q: if not q:
return [] return []
primary = q.split(",")[0].strip() or q primary = primary_search_query(q) or q
like_full = f"%{q}%" like_full = f"%{q}%"
like_primary = f"%{primary}%" like_primary = f"%{primary}%"
prefix = f"{primary}%" prefix = f"{primary}%"
norm = normalize_food_name(primary) norm = normalize_food_name(primary)
extra_sql = ""
extra_params: list[str] = []
must = _catalog_must_tokens(q)
if len(must) >= 2:
parts = []
for group in must:
ors = " OR ".join(["name_de ILIKE %s"] * len(group))
parts.append(f"({ors})")
extra_params.extend(f"%{v}%" for v in group)
extra_sql = " OR (" + " AND ".join(parts) + ")"
cur.execute( cur.execute(
""" f"""
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
FROM food_catalog FROM food_catalog
WHERE is_active = true WHERE is_active = true
@ -382,6 +426,7 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int =
OR COALESCE(name_en, '') ILIKE %s OR COALESCE(name_en, '') ILIKE %s OR COALESCE(name_en, '') ILIKE %s OR COALESCE(name_en, '') ILIKE %s
OR COALESCE(bls_code, '') ILIKE %s OR COALESCE(bls_code, '') ILIKE %s
OR lower(name_de) = %s OR lower(name_de) = %s
{extra_sql}
) )
ORDER BY ORDER BY
CASE CASE
@ -395,6 +440,7 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int =
( (
profile_id, profile_id,
like_full, like_primary, like_full, like_primary, like_full, norm, like_full, like_primary, like_full, like_primary, like_full, norm,
*extra_params,
norm, prefix, q, limit, norm, prefix, q, limit,
), ),
) )

View File

@ -108,12 +108,12 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
recipes = [dict(r) for r in cur.fetchall()] recipes = [dict(r) for r in cur.fetchall()]
if not recipes: if not recipes:
return [] return []
ids = [r["id"] for r in recipes] ids = [str(r["id"]) for r in recipes]
cur.execute( cur.execute(
""" """
SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order
FROM food_recipe_ingredients FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s) WHERE recipe_id = ANY(%s::uuid[])
ORDER BY sort_order, source_name_raw ORDER BY sort_order, source_name_raw
""", """,
(ids,), (ids,),
@ -122,10 +122,109 @@ def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
for row in cur.fetchall(): for row in cur.fetchall():
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row)) by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
for rec in recipes: for rec in recipes:
rec["ingredients"] = by_r.get(str(rec["id"]), []) rec["id"] = str(rec["id"])
rec["ingredients"] = by_r.get(rec["id"], [])
return recipes return recipes
def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None:
for rec in list_recipes(cur, profile_id):
if rec["id"] == str(recipe_id):
return rec
return None
def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]:
name_raw = (rec.get("name_raw") or "").strip()
norm = rec.get("name_normalized") or normalize_food_name(name_raw)
if not name_raw or not norm:
raise ValueError("Rezeptname fehlt")
try:
portions = float(rec.get("portions") or 1)
except (TypeError, ValueError):
portions = 1.0
if portions <= 0:
portions = 1.0
description = rec.get("description")
source = (rec.get("source") or "manual").strip() or "manual"
if recipe_id:
cur.execute(
"SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
if not cur.fetchone():
raise KeyError("Rezept nicht gefunden")
cur.execute(
"""
SELECT id FROM food_recipes
WHERE profile_id = %s AND name_normalized = %s AND id <> %s
""",
(profile_id, norm, recipe_id),
)
if cur.fetchone():
raise ValueError("Ein Rezept mit diesem Namen existiert bereits")
cur.execute(
"""
UPDATE food_recipes
SET name_raw=%s, name_normalized=%s, portions=%s, description=%s, updated_at=NOW()
WHERE id=%s AND profile_id=%s
""",
(name_raw, norm, portions, description, recipe_id, profile_id),
)
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (recipe_id,))
rid = recipe_id
else:
cur.execute(
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
(profile_id, norm),
)
existing = cur.fetchone()
if existing:
return save_recipe(cur, profile_id, rec, str(existing["id"]))
rid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO food_recipes
(id, profile_id, name_raw, name_normalized, portions, description, source)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(rid, profile_id, name_raw, norm, portions, description, source),
)
for i, ing in enumerate(rec.get("ingredients") or []):
raw = (ing.get("source_name_raw") or "").strip()
inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
if not inorm:
continue
qty = ing.get("quantity_g")
try:
qty = float(qty) if qty not in (None, "") else None
except (TypeError, ValueError):
qty = None
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(str(uuid.uuid4()), rid, raw or inorm, inorm, ing.get("quantity_raw"), qty, i),
)
link_recipes_to_items(cur, profile_id)
saved = get_recipe(cur, profile_id, rid)
if not saved:
raise ValueError("Rezept konnte nicht gelesen werden")
return saved
def delete_recipe(cur, profile_id: str, recipe_id: str) -> None:
cur.execute(
"DELETE FROM food_recipes WHERE id = %s AND profile_id = %s RETURNING id",
(recipe_id, profile_id),
)
if not cur.fetchone():
raise KeyError("Rezept nicht gefunden")
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int: def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
cur.execute( cur.execute(
""" """

View File

@ -5,7 +5,7 @@ import re
import time import time
from typing import Any from typing import Any
from data_layer.food_mapping import normalize_food_name from data_layer.food_mapping import normalize_food_name, primary_search_query
_INDEX_TTL_SEC = 300 _INDEX_TTL_SEC = 300
_index_cache: dict[str, tuple[float, dict[str, Any]]] = {} _index_cache: dict[str, tuple[float, dict[str, Any]]] = {}
@ -14,32 +14,106 @@ MAX_BUCKET = 40
SPLIT_RE = re.compile(r"[^a-z0-9äöüß]+") SPLIT_RE = re.compile(r"[^a-z0-9äöüß]+")
COLLAPSE_RE = re.compile(r"[^a-z0-9äöüß]") COLLAPSE_RE = re.compile(r"[^a-z0-9äöüß]")
NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
NUM_TOKEN_RE = re.compile(r"^\d+(?:\.\d+)?$")
MIN_SCORE = 45 MIN_SCORE = 45
FAT_CLOSE = 0.6
def collapse_key(raw: str | None) -> str: def collapse_key(raw: str | None) -> str:
return COLLAPSE_RE.sub("", normalize_food_name(raw)) return COLLAPSE_RE.sub("", normalize_food_name(raw))
def number_tokens(raw: str | None) -> list[str]:
return [m.replace(",", ".") for m in NUM_RE.findall(normalize_food_name(raw))]
def is_number_token(tok: str) -> bool:
return bool(NUM_TOKEN_RE.fullmatch(tok or ""))
def number_search_aliases(tok: str) -> list[str]:
"""9.5 and 10 are the same fat class in dairy; keep both searchable."""
if not is_number_token(tok):
return [tok]
aliases = {tok}
try:
value = float(tok)
except ValueError:
return [tok]
if abs(value - 10) <= FAT_CLOSE or abs(value - 9.5) <= FAT_CLOSE:
aliases.update({"9.5", "10"})
if value == int(value):
aliases.add(str(int(value)))
return list(aliases)
def numbers_compatible(query_nums: list[str], name_nums: list[str]) -> bool | None:
if not query_nums:
return None
if not name_nums:
return False
for qn in query_nums:
try:
qv = float(qn)
except ValueError:
continue
for nn in name_nums:
try:
if abs(qv - float(nn)) <= FAT_CLOSE:
return True
except ValueError:
if qn == nn:
return True
return False
def name_tokens(raw: str | None) -> list[str]: def name_tokens(raw: str | None) -> list[str]:
return [t for t in SPLIT_RE.split(normalize_food_name(raw)) if len(t) >= 2] text = normalize_food_name(raw)
nums = number_tokens(text)
words = [t for t in SPLIT_RE.split(NUM_RE.sub(" ", text)) if len(t) >= 2]
return words + nums
def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int: def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int:
qn = normalize_food_name((query or "").split(",")[0]) qn = normalize_food_name(primary_search_query(query))
nn = normalize_food_name(name_de) nn = normalize_food_name(name_de)
if not qn or not nn: if not qn or not nn:
return 0 return 0
qc, nc = collapse_key(qn), collapse_key(nn) qc, nc = collapse_key(qn), collapse_key(nn)
q_nums, n_nums = number_tokens(qn), number_tokens(nn)
fat_ok = numbers_compatible(q_nums, n_nums)
if qn == nn: if qn == nn:
return 100 return 100
if qc and qc == nc: if qc and qc == nc:
return 95 return 95
qt, nt = set(name_tokens(qn)), set(name_tokens(nn))
q_words = {t for t in qt if not is_number_token(t)}
n_words = {t for t in nt if not is_number_token(t)}
words_overlap = bool(q_words and n_words and (q_words & n_words or q_words <= n_words))
if fat_ok and words_overlap:
return 96
if fat_ok is False:
base = 0
if qc and nc.startswith(qc) and len(qc) >= 4:
base = 82
elif nc and qc.startswith(nc) and len(nc) >= 4:
base = 78
elif qt and qt <= nt:
base = 72
elif nt and nt <= qt:
base = 68
elif qt and nt:
overlap = len(qt & nt) / len(qt | nt)
if overlap >= 0.5:
base = 50 + int(overlap * 20)
elif qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
base = 55 if abs(len(qc) - len(nc)) <= 8 else 46
return min(base, 52) if base else 0
if qc and nc.startswith(qc) and len(qc) >= 4: if qc and nc.startswith(qc) and len(qc) >= 4:
return 82 return 82
if nc and qc.startswith(nc) and len(nc) >= 4: if nc and qc.startswith(nc) and len(nc) >= 4:
return 78 return 78
qt, nt = set(name_tokens(qn)), set(name_tokens(nn))
if qt and qt <= nt: if qt and qt <= nt:
return 72 return 72
if nt and nt <= qt: if nt and nt <= qt:
@ -148,15 +222,27 @@ def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]:
if len(suffix_hits) <= MAX_BUCKET: if len(suffix_hits) <= MAX_BUCKET:
for food in suffix_hits: for food in suffix_hits:
add(food) add(food)
q_words = [t for t in name_tokens(query) if not is_number_token(t)]
seen_tok: set[str] = set()
for tok in name_tokens(query): for tok in name_tokens(query):
token_hits = index["by_token"].get(tok, []) probes = number_search_aliases(tok) if is_number_token(tok) else [tok]
if len(token_hits) > MAX_BUCKET: for probe in probes:
if probe in seen_tok:
continue
seen_tok.add(probe)
token_hits = index["by_token"].get(probe, [])
if is_number_token(probe) and q_words:
token_hits = [
food for food in token_hits
if set(q_words) & set(food.get("_t") or [])
]
elif not is_number_token(probe) and len(token_hits) > MAX_BUCKET:
token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET] token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET]
for food in token_hits: for food in token_hits:
add(food) add(food)
if len(out) >= MAX_CANDIDATES: if len(out) >= MAX_CANDIDATES and not any(is_number_token(t) for t in name_tokens(query)):
break break
return out[:MAX_CANDIDATES] return out[:MAX_CANDIDATES] if not number_tokens(query) else out
def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]: def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
@ -196,11 +282,22 @@ def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit:
if len(q) < 2: if len(q) < 2:
return [] return []
index = get_suggest_index(cur, profile_id) index = get_suggest_index(cur, profile_id)
packed = suggest_for_name(index, q, limit=limit) packed = suggest_for_name(index, q, limit=max(limit, 8))
if packed["suggestions"]: hits = list(packed["suggestions"])
return packed["suggestions"]
from data_layer.food_mapping import suggest_catalog_foods from data_layer.food_mapping import suggest_catalog_foods
return suggest_catalog_foods(cur, q, profile_id, limit=limit) if number_tokens(q) or not hits:
seen = {str(h["id"]) for h in hits}
for row in suggest_catalog_foods(cur, q, profile_id, limit=max(limit, 20)):
fid = str(row["id"])
if fid in seen:
continue
seen.add(fid)
score = score_name_match(q, row.get("name_de") or "", row.get("name_en"))
if score < MIN_SCORE and not number_tokens(q):
continue
hits.append(_public(row, score or 40))
hits.sort(key=lambda h: (-int(h.get("score") or 0), h.get("name_de") or ""))
return hits[:limit]
def suggest_batch(cur, profile_id: str | None, names: list[str], limit: int = 3) -> dict[str, dict[str, Any]]: def suggest_batch(cur, profile_id: str | None, names: list[str], limit: int = 3) -> dict[str, dict[str, Any]]:

View File

@ -0,0 +1,13 @@
-- Common supplement fatty acids if BLS did not import these exact keys.
-- ON CONFLICT keeps official BLS rows unchanged.
INSERT INTO food_attributes
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order)
VALUES
('EPA', 'Eicosapentaensäure (EPA)', 'EPA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8001),
('DHA', 'Docosahexaensäure (DHA)', 'DHA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8002),
('DPA', 'Docosapentaensäure (DPA)', 'DPA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8003),
('ALA', 'Alpha-Linolensäure (ALA)', 'ALA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8004),
('OMEGA3', 'Omega-3-Fettsäuren gesamt', 'Omega-3', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8005),
('OMEGA6', 'Omega-6-Fettsäuren gesamt', 'Omega-6', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8006)
ON CONFLICT (attr_key) DO NOTHING;

View File

@ -264,21 +264,5 @@ def admin_create_attribute(body: AttributeCreate, session: dict = Depends(requir
def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None: def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None:
if not macros: from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
return write_numeric_attributes(cur, food_id, macros_and_attributes_to_values(macros, None))
mapping = {"kcal": "ENERCC", "protein_g": "PROT625", "fat_g": "FAT", "carbs_g": "CHO"}
for field, key in mapping.items():
if field not in macros or macros[field] is None:
continue
cur.execute("SELECT id FROM food_attributes WHERE attr_key = %s", (key,))
row = cur.fetchone()
if not row:
continue
cur.execute(
"""
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
VALUES (%s, %s, %s, false)
ON CONFLICT (food_id, attribute_id) DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
""",
(food_id, row["id"], float(macros[field])),
)

View File

@ -7,7 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from auth import require_admin from auth import require_admin
from data_layer.food_mapping import upsert_food_mapping from data_layer.food_mapping import apply_mapping_to_items, upsert_food_mapping
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
from db import get_cursor, get_db, r2d from db import get_cursor, get_db, r2d
router = APIRouter(prefix="/api/admin/food-mappings", tags=["admin", "food-mappings"]) router = APIRouter(prefix="/api/admin/food-mappings", tags=["admin", "food-mappings"])
@ -20,6 +21,12 @@ class FoodMappingCreate(BaseModel):
source_system: str = "fddb" source_system: str = "fddb"
class FoodMappingUpdate(BaseModel):
food_id: str
grams_per_unit: Optional[float] = None
source_unit: Optional[str] = None
@router.get("") @router.get("")
def list_food_mappings( def list_food_mappings(
profile_id: Optional[str] = None, profile_id: Optional[str] = None,
@ -93,6 +100,49 @@ def create_food_mapping(body: FoodMappingCreate, session: dict = Depends(require
return r2d(cur.fetchone()) return r2d(cur.fetchone())
@router.put("/{mapping_id}")
def update_food_mapping(mapping_id: int, body: FoodMappingUpdate, session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, source_name_raw, source_name_normalized, profile_id
FROM food_name_mappings WHERE id = %s
""",
(mapping_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Mapping nicht gefunden")
cur.execute("SELECT id FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,))
if not cur.fetchone():
raise HTTPException(404, "Lebensmittel nicht gefunden")
cur.execute(
"""
UPDATE food_name_mappings
SET food_id = %s, grams_per_unit = COALESCE(%s, grams_per_unit),
source_unit = COALESCE(%s, source_unit), source = 'admin', updated_at = NOW()
WHERE id = %s
""",
(body.food_id, body.grams_per_unit, body.source_unit, mapping_id),
)
if row.get("profile_id"):
apply_mapping_to_items(cur, str(row["profile_id"]), row["source_name_normalized"], body.food_id, mapping_id)
dates = dates_for_normalized_name(cur, str(row["profile_id"]), row["source_name_normalized"])
for d in dates:
rebuild_daily_nutrients(cur, str(row["profile_id"]), d)
cur.execute(
"""
SELECT m.*, f.name_de AS food_name_de, f.bls_code
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.id = %s
""",
(mapping_id,),
)
return r2d(cur.fetchone())
@router.delete("/{mapping_id}") @router.delete("/{mapping_id}")
def delete_food_mapping(mapping_id: int, session: dict = Depends(require_admin)): def delete_food_mapping(mapping_id: int, session: dict = Depends(require_admin)):
with get_db() as conn: with get_db() as conn:

View File

@ -34,6 +34,8 @@ class UserFoodCreate(BaseModel):
name_de: str name_de: str
name_en: Optional[str] = None name_en: Optional[str] = None
macros_per_100g: Optional[dict] = None macros_per_100g: Optional[dict] = None
attributes: Optional[dict] = None
serving_g: Optional[float] = None
class MappingUpsert(BaseModel): class MappingUpsert(BaseModel):
@ -89,6 +91,17 @@ def search_foods(
return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50)) return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50))
@router.get("/attributes")
def list_food_attributes(
q: str = "",
limit: int = 40,
session: dict = Depends(require_auth),
):
from data_layer.food_attributes import list_numeric_attributes
with get_db() as conn:
return list_numeric_attributes(get_cursor(conn), q, limit=limit)
@router.post("/foods/suggest-batch") @router.post("/foods/suggest-batch")
def suggest_foods_batch( def suggest_foods_batch(
body: SuggestBatchBody, body: SuggestBatchBody,
@ -107,13 +120,15 @@ def create_user_food(
x_profile_id: Optional[str] = Header(default=None), x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth), session: dict = Depends(require_auth),
): ):
from routers.admin_bls import _write_manual_macros from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
import uuid import uuid
pid = _pid(session, x_profile_id) pid = _pid(session, x_profile_id)
name = (body.name_de or "").strip() name = (body.name_de or "").strip()
if not name: if not name:
raise HTTPException(400, "Name fehlt") raise HTTPException(400, "Name fehlt")
serving = body.serving_g if body.serving_g and body.serving_g > 0 else None
values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
@ -126,7 +141,7 @@ def create_user_food(
(name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:32]}-{uuid.uuid4().hex[:8]}"), (name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:32]}-{uuid.uuid4().hex[:8]}"),
) )
food = r2d(cur.fetchone()) food = r2d(cur.fetchone())
_write_manual_macros(cur, food["id"], body.macros_per_100g) write_numeric_attributes(cur, food["id"], values)
invalidate_suggest_index(pid) invalidate_suggest_index(pid)
return food return food

View File

@ -12,6 +12,7 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
@ -23,6 +24,19 @@ router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RecipeIngredientIn(BaseModel):
source_name_raw: str
quantity_raw: Optional[str] = None
quantity_g: Optional[float] = None
class RecipeIn(BaseModel):
name_raw: str
portions: float = 1
description: Optional[str] = None
ingredients: list[RecipeIngredientIn] = []
# ── Helper ──────────────────────────────────────────────────────────────────── # ── Helper ────────────────────────────────────────────────────────────────────
def _pf(s): def _pf(s):
"""Parse float from string (handles comma decimal separator).""" """Parse float from string (handles comma decimal separator)."""
@ -441,6 +455,55 @@ def list_food_recipes(
return list_recipes(get_cursor(conn), pid) return list_recipes(get_cursor(conn), pid)
@router.post("/recipes")
def create_food_recipe(
body: RecipeIn,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import save_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
return save_recipe(get_cursor(conn), pid, body.model_dump())
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.put("/recipes/{recipe_id}")
def update_food_recipe(
recipe_id: str,
body: RecipeIn,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import save_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
return save_recipe(get_cursor(conn), pid, body.model_dump(), recipe_id)
except KeyError:
raise HTTPException(404, "Rezept nicht gefunden") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.delete("/recipes/{recipe_id}")
def delete_food_recipe(
recipe_id: str,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import delete_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
delete_recipe(get_cursor(conn), pid, recipe_id)
except KeyError:
raise HTTPException(404, "Rezept nicht gefunden") from None
return {"ok": True}
@router.post("/recipes/import-fddb-lists") @router.post("/recipes/import-fddb-lists")
async def import_fddb_lists( async def import_fddb_lists(
file: UploadFile = File(...), file: UploadFile = File(...),
@ -506,9 +569,13 @@ def export_food_knowledge_file(
from data_layer.food_knowledge import export_food_knowledge from data_layer.food_knowledge import export_food_knowledge
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
try:
with get_db() as conn: with get_db() as conn:
bundle = export_food_knowledge(get_cursor(conn), pid) bundle = export_food_knowledge(get_cursor(conn), pid)
body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str) body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str)
except Exception:
logger.exception("Export food-knowledge fehlgeschlagen")
raise HTTPException(500, "Export fehlgeschlagen") from None
stamp = datetime.now().strftime("%Y-%m-%d") stamp = datetime.now().strftime("%Y-%m-%d")
return Response( return Response(
content=body.encode("utf-8"), content=body.encode("utf-8"),

View File

@ -0,0 +1,19 @@
from data_layer.food_attributes import macros_and_attributes_to_values, scale_to_per_100g
def test_scale_serving_to_per_100g():
assert scale_to_per_100g(1.1, 8) == 1.1 * (100 / 8)
assert scale_to_per_100g(10, 100) == 10
assert scale_to_per_100g(10, None) == 10
def test_norsan_label_epa_converts():
"""Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g."""
vals = macros_and_attributes_to_values(
{"kcal": 72, "protein_g": 0, "fat_g": 8, "carbs_g": 0},
{"EPA": 1.1},
serving_g=8,
)
assert abs(vals["FAT"] - 100) < 0.01
assert abs(vals["EPA"] - 13.75) < 0.01
assert abs(vals["ENERCC"] - 900) < 0.01

View File

@ -1,5 +1,9 @@
from decimal import Decimal
from uuid import uuid4
from data_layer.food_knowledge import ( from data_layer.food_knowledge import (
BUNDLE_FORMAT, BUNDLE_FORMAT,
_jsonable,
parse_food_knowledge_bundle, parse_food_knowledge_bundle,
portable_mapping, portable_mapping,
) )
@ -40,3 +44,14 @@ def test_portable_mapping_drops_ids():
assert "food_id" not in row assert "food_id" not in row
assert row["bls_code"] == "C131000" assert row["bls_code"] == "C131000"
assert row["source_name_raw"] == "Haferflocken, Großblatt" assert row["source_name_raw"] == "Haferflocken, Großblatt"
def test_jsonable_export_types():
payload = _jsonable({
"grams": Decimal("1.50"),
"id": uuid4(),
"nested": [Decimal("2")],
})
assert isinstance(payload["grams"], float)
assert isinstance(payload["id"], str)
assert payload["nested"] == [2.0]

View File

@ -7,10 +7,16 @@ from data_layer.food_mapping import (
normalize_food_name, normalize_food_name,
parse_quantity, parse_quantity,
parse_quantity_g, parse_quantity_g,
primary_search_query,
) )
from data_layer.nutrition_items import macros_differ from data_layer.nutrition_items import macros_differ
def test_primary_query_keeps_decimal_comma():
assert primary_search_query("Joghurt 9,5%") == "Joghurt 9,5%"
assert primary_search_query("Joghurt, aus Kuhmilch") == "Joghurt"
def test_normalize_strips_leading_quantity(): def test_normalize_strips_leading_quantity():
assert normalize_food_name("50 g Hähnchen") == "hähnchen" assert normalize_food_name("50 g Hähnchen") == "hähnchen"
assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%" assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%"

View File

@ -0,0 +1,10 @@
from data_layer.food_recipes import save_recipe
def test_save_recipe_requires_name():
try:
save_recipe(None, "p", {"name_raw": " ", "ingredients": []})
except ValueError as e:
assert "Rezeptname" in str(e)
else:
raise AssertionError("expected ValueError")

View File

@ -1,4 +1,25 @@
from data_layer.food_suggest import collapse_key, score_name_match, suggest_batch, suggest_for_name from data_layer.food_suggest import (
collapse_key,
name_tokens,
score_name_match,
suggest_batch,
suggest_for_name,
)
def _index(*foods):
index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}}
for food in foods:
food["_c"] = collapse_key(food["name_de"])
food["_t"] = name_tokens(food["name_de"])
if food["_c"]:
index["by_collapse"].setdefault(food["_c"], []).append(food)
index["by_prefix"].setdefault(food["_c"][:4], []).append(food)
if len(food["_c"]) >= 4:
index["by_suffix"].setdefault(food["_c"][-4:], []).append(food)
for tok in food["_t"]:
index["by_token"].setdefault(tok, []).append(food)
return index
def test_collapse_treats_space_as_same(): def test_collapse_treats_space_as_same():
@ -63,3 +84,24 @@ def test_suggest_batch_dedupes_names():
out = suggest_batch(_Cur(), None, ["Haferflocken", "Haferflocken", ""], limit=2) out = suggest_batch(_Cur(), None, ["Haferflocken", "Haferflocken", ""], limit=2)
assert "Haferflocken" in out assert "Haferflocken" in out
assert out["Haferflocken"]["suggestions"] == [] assert out["Haferflocken"]["suggestions"] == []
def test_number_tokens_keep_decimal_fat():
assert "9.5" in name_tokens("Joghurt 9,5%")
assert "10" in name_tokens("Joghurt, aus Kuhmilch, 10 % Fett")
def test_joghurt_10_ranks_above_35():
light = {"id": "a", "name_de": "Joghurt >3,5% Fett", "name_en": "", "bls_code": "M", "catalog_kind": "official_bls"}
full = {
"id": "b",
"name_de": "Joghurt, aus Kuhmilch, 10 % Fett",
"name_en": "",
"bls_code": "N",
"catalog_kind": "official_bls",
}
index = _index(light, full)
for query in ("Joghurt 10%", "Joghurt 9,5%", "Joghurt 10"):
packed = suggest_for_name(index, query, limit=3)
assert packed["suggestions"][0]["name_de"] == full["name_de"], query
assert packed["suggestions"][0]["score"] > score_name_match(query, light["name_de"])

View File

@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
APP_VERSION = "0.9v" APP_VERSION = "0.9v"
BUILD_DATE = "2026-09-12" BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260912" # 064 mapping units DB_SCHEMA_VERSION = "20260912b" # 065 omega attribute extensions
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.0", "auth": "1.2.0",
@ -20,8 +20,8 @@ MODULE_VERSIONS = {
"circumference": "1.0.1", "circumference": "1.0.1",
"caliper": "1.0.1", "caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.2.5", # unmapped since_days: last weeks first "nutrition": "1.2.9", # Zuordnen: Lebensmittel oder Rezept anlegen
"bls": "1.0.2", "bls": "1.0.4",
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
"prompts": "1.1.0", "prompts": "1.1.0",
@ -52,6 +52,9 @@ CHANGELOG = [
"Inline-Vorschläge (Haferflocken → Hafer Flocken), Bestätigen in der Zeile, Neu anlegen", "Inline-Vorschläge (Haferflocken → Hafer Flocken), Bestätigen in der Zeile, Neu anlegen",
"Zuordnen: Suche und Bestätigen ohne Browser-Freeze (Index-Cache, Batch-Vorschläge, kein Seiten-Reload)", "Zuordnen: Suche und Bestätigen ohne Browser-Freeze (Index-Cache, Batch-Vorschläge, kein Seiten-Reload)",
"Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen", "Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen",
"Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%",
"Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g",
"Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern",
], ],
}, },
{ {

View File

@ -18,3 +18,4 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
- FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen - FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen - Zuordnungen und Listen als JSON zwischen Instanzen übertragen
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping - Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung

View File

@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
function parseNum(raw) {
const n = parseFloat(String(raw ?? '').replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
export function foodCreatePayload(name, macros, extras, servingG) {
const attributes = {}
for (const row of extras || []) {
if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue
attributes[row.attr.attr_key] = parseNum(row.value)
}
const serving = parseNum(servingG)
return {
name_de: (name || '').trim(),
macros_per_100g: {
kcal: parseNum(macros.kcal),
protein_g: parseNum(macros.protein_g),
fat_g: parseNum(macros.fat_g),
carbs_g: parseNum(macros.carbs_g),
},
attributes,
serving_g: serving > 0 && serving !== 100 ? serving : null,
}
}
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) {
const [q, setQ] = useState('')
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
const timer = useRef(null)
useEffect(() => () => clearTimeout(timer.current), [])
const search = (term) => {
clearTimeout(timer.current)
const query = (term || '').trim()
if (query.length < 2) {
setHits([])
return
}
timer.current = setTimeout(async () => {
setLoading(true)
try {
setHits(await api.listFoodAttributes(query, 20))
} catch {
setHits([])
} finally {
setLoading(false)
}
}, 250)
}
const add = (attr) => {
setExtras((list) => (
list.some((r) => r.attr.attr_key === attr.attr_key) ? list : [...list, { attr, value: '' }]
))
setQ('')
setHits([])
}
const setValue = (key, value) => {
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r)))
}
return (
<div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>
Zahlen in der Einheit des Stoffs. Bei Etikett pro 8 ml / 8 g die Portionsgröße setzen gespeichert wird immer pro 100 g.
</p>
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
Angabe gilt für{' '}
<input
className="form-input"
type="number"
min="0.1"
step="0.1"
style={{ width: 80, display: 'inline-block', textAlign: 'left' }}
value={servingG}
onChange={(e) => setServingG(e.target.value)}
/>
{' '}g (100 = pro 100 g)
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
<label key={key} style={{ fontSize: 12, color: 'var(--text2)' }}>
{label}
<input
className="form-input"
type="number"
min="0"
step="0.1"
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
value={macros[key]}
onChange={(e) => setMacros({ ...macros, [key]: e.target.value })}
/>
</label>
))}
</div>
<p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe (EPA, DHA, Ballaststoffe, )</p>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Stoff suchen, z. B. EPA oder Omega"
value={q}
onChange={(e) => { setQ(e.target.value); search(e.target.value) }}
/>
{loading && <p style={{ fontSize: 12, color: 'var(--text3)' }}>Suche</p>}
{hits.map((h) => (
<button
key={h.attr_key}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 6, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => add(h)}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 11, color: 'var(--text3)' }}>{h.attr_key}{h.unit ? ` · ${h.unit}` : ''}{h.category ? ` · ${h.category}` : ''}</span>
</span>
</button>
))}
{extras.map((row) => (
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
{row.attr.name_de}{row.attr.unit ? ` (${row.attr.unit})` : ''}
<input
className="form-input"
type="number"
min="0"
step="0.001"
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
value={row.value}
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
placeholder="Wert in der Einheit oben"
/>
</label>
))}
</div>
)
}

View File

@ -0,0 +1,183 @@
import { useState } from 'react'
import { api } from '../utils/api'
function emptyIng() {
return { source_name_raw: '', quantity_g: '' }
}
function mappingHint(name, learned) {
const n = (name || '').trim().toLowerCase()
if (!n) return null
return (learned || []).find((m) => {
const raw = (m.source_name_raw || '').toLowerCase()
const norm = (m.source_name_normalized || '').toLowerCase()
return raw === n || norm === n || raw.includes(n) || n.includes(norm)
}) || null
}
export function RecipeForm({ defaultName, defaultPortions, defaultIngredients, learned, disabled, submitLabel, onSave, onCancel }) {
const [name, setName] = useState(defaultName || '')
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
const [ings, setIngs] = useState(
(defaultIngredients || []).length
? defaultIngredients.map((i) => ({ source_name_raw: i.source_name_raw || '', quantity_g: i.quantity_g ?? '' }))
: [emptyIng()]
)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const save = async () => {
const ingredients = ings
.map((i) => ({
source_name_raw: (i.source_name_raw || '').trim(),
quantity_g: i.quantity_g === '' ? null : parseFloat(String(i.quantity_g).replace(',', '.')),
}))
.filter((i) => i.source_name_raw)
if (!name.trim()) {
setError('Rezeptname fehlt')
return
}
if (!ingredients.length) {
setError('Mindestens eine Zutat angeben')
return
}
setSaving(true)
setError(null)
try {
await onSave({
name_raw: name.trim(),
portions: parseFloat(String(portions).replace(',', '.')) || 1,
ingredients,
})
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>
Zutaten, die schon zugeordnet sind, werden übernommen. Offene erscheinen danach in der Liste zum Zuordnen.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} placeholder="Rezeptname" value={name} onChange={(e) => setName(e.target.value)} />
<label style={{ fontSize: 12, color: 'var(--text2)' }}>
Portionen
<input className="form-input" type="number" min="0.1" step="0.1" style={{ width: 80, textAlign: 'left', marginLeft: 8 }} value={portions} onChange={(e) => setPortions(e.target.value)} />
</label>
<p style={{ fontSize: 12, margin: '10px 0 6px', color: 'var(--text2)' }}>Zutaten (Name + Gramm)</p>
{ings.map((ing, i) => {
const mapped = mappingHint(ing.source_name_raw, learned)
return (
<div key={i} style={{ marginBottom: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px', gap: 6 }}>
<input className="form-input" style={{ textAlign: 'left' }} placeholder="z. B. Haferflocken" value={ing.source_name_raw} onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value } : x))} />
<input className="form-input" type="number" min="0" step="0.1" style={{ textAlign: 'left' }} placeholder="g" value={ing.quantity_g} onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, quantity_g: e.target.value } : x))} />
</div>
{ing.source_name_raw.trim() && (
<div style={{ fontSize: 11, color: mapped ? 'var(--accent-dark)' : 'var(--text3)', marginTop: 2 }}>
{mapped ? `zugeordnet: ${mapped.food_name_de || mapped.source_name_raw}` : 'noch offen — nach dem Speichern in der Liste zuordnen'}
</div>
)}
</div>
)
})}
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setIngs((list) => [...list, emptyIng()])}>Zutat hinzufügen</button>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
{onCancel && (
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onCancel}>Zurück</button>
)}
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={disabled || saving} onClick={save}>
{saving ? 'Speichere…' : (submitLabel || 'Rezept speichern')}
</button>
</div>
</div>
)
}
export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(null)
const [draft, setDraft] = useState(null)
const [error, setError] = useState(null)
const startNew = () => {
setEditing('new')
setDraft({ name: '', portions: '1' })
setError(null)
setOpen(true)
}
const startEdit = (r) => {
setEditing(r)
setDraft(r)
setError(null)
setOpen(true)
}
const save = async (body) => {
if (editing === 'new') await api.createNutritionRecipe(body)
else await api.updateNutritionRecipe(editing.id, body)
setEditing(null)
setDraft(null)
onChanged?.()
}
const remove = async (id) => {
if (!confirm('Rezept wirklich löschen?')) return
try {
await api.deleteNutritionRecipe(id)
if (editing && editing !== 'new' && editing.id === id) setEditing(null)
onChanged?.()
} catch (e) {
setError(e.message)
}
}
return (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<h3 style={{ fontSize: 14, margin: 0 }}>Eigene Rezepte / Listen ({recipes.length})</h3>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => { setOpen((v) => !v); if (open) setEditing(null) }}>
{open ? 'Einklappen' : 'Anzeigen'}
</button>
</div>
{open && (
<div style={{ marginTop: 8 }}>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
<button type="button" className="btn btn-secondary btn-full" onClick={startNew}>Rezept anlegen</button>
{recipes.map((r) => (
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{(r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
{r.source === 'manual' ? ' · selbst angelegt' : ''}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => remove(r.id)}>Löschen</button>
</div>
</div>
))}
{editing === 'new' && (
<RecipeForm learned={learned} submitLabel="Rezept anlegen" onSave={save} onCancel={() => setEditing(null)} />
)}
{editing && editing !== 'new' && (
<RecipeForm
key={editing.id}
defaultName={draft?.name_raw}
defaultPortions={draft?.portions}
defaultIngredients={draft?.ingredients}
learned={learned}
submitLabel="Rezept speichern"
onSave={save}
onCancel={() => setEditing(null)}
/>
)}
</div>
)}
</div>
)
}

View File

@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import { RecipeForm } from './FoodRecipeEditor'
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse']) const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
const UNIT_LABEL = { const UNIT_LABEL = {
@ -24,17 +26,20 @@ function detectUnit(...texts) {
return null return null
} }
export default function FoodSearchModal({ title, initialQuery, quantityHint, onSelect, onClose }) { export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, learned, onSelect, onCreateRecipe, onClose }) {
const [q, setQ] = useState(initialQuery || '') const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([]) const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [createKind, setCreateKind] = useState(null)
const [manual, setManual] = useState({ const [manual, setManual] = useState({
name_de: initialQuery || '', name_de: initialQuery || '',
kcal: '', protein_g: '', fat_g: '', carbs_g: '', kcal: '', protein_g: '', fat_g: '', carbs_g: '',
}) })
const [nutrientExtras, setNutrientExtras] = useState([])
const [servingG, setServingG] = useState('100')
const suggestedUnit = detectUnit(quantityHint, initialQuery) const suggestedUnit = detectUnit(quantityHint, initialQuery)
const [gramsPerUnit, setGramsPerUnit] = useState( const [gramsPerUnit, setGramsPerUnit] = useState(
suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : '' suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : ''
@ -44,7 +49,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
const abortRef = useRef(null) const abortRef = useRef(null)
const seqRef = useRef(0) const seqRef = useRef(0)
const extras = () => { const unitExtras = () => {
const g = parseFloat(String(gramsPerUnit).replace(',', '.')) const g = parseFloat(String(gramsPerUnit).replace(',', '.'))
if (!suggestedUnit || !g || g <= 0) return {} if (!suggestedUnit || !g || g <= 0) return {}
return { grams_per_unit: g, source_unit: suggestedUnit } return { grams_per_unit: g, source_unit: suggestedUnit }
@ -111,16 +116,8 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
setCreating(true) setCreating(true)
setError(null) setError(null)
try { try {
const food = await api.createUserFood({ const food = await api.createUserFood(foodCreatePayload(name, manual, nutrientExtras, servingG))
name_de: name, onSelect(food, unitExtras())
macros_per_100g: {
kcal: parseFloat(String(manual.kcal).replace(',', '.')) || 0,
protein_g: parseFloat(String(manual.protein_g).replace(',', '.')) || 0,
fat_g: parseFloat(String(manual.fat_g).replace(',', '.')) || 0,
carbs_g: parseFloat(String(manual.carbs_g).replace(',', '.')) || 0,
},
})
onSelect(food, extras())
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} finally { } finally {
@ -160,12 +157,12 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
ref={inputRef} ref={inputRef}
className="form-input" className="form-input"
style={{ width: '100%', textAlign: 'left' }} style={{ width: '100%', textAlign: 'left' }}
placeholder="Name eingeben, z. B. Haferflocken" placeholder="Name und Fettgehalt, z. B. Joghurt 10%"
value={q} value={q}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}> <p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
Suche nach dem Namen. Fehlt der Treffer, legst du unten einen eigenen Eintrag an. Fettgehalt mit eingeben, z. B. Joghurt 10 % oder 9,5 %. Ohne Zahl erscheint oft nur die 3,5 %-Klasse. Fehlt der Treffer, eigenen Eintrag anlegen.
</p> </p>
{suggestedUnit && ( {suggestedUnit && (
<label style={{ display: 'block', marginTop: 10, fontSize: 13 }}> <label style={{ display: 'block', marginTop: 10, fontSize: 13 }}>
@ -196,7 +193,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
type="button" type="button"
className="btn btn-secondary btn-full" className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h, extras())} onClick={() => onSelect(h, unitExtras())}
> >
<span> <span>
<strong style={{ display: 'block' }}>{h.name_de}</strong> <strong style={{ display: 'block' }}>{h.name_de}</strong>
@ -213,46 +210,63 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
{!showCreate ? ( {!showCreate ? (
<button type="button" className="btn btn-secondary btn-full" onClick={() => { <button type="button" className="btn btn-secondary btn-full" onClick={() => {
setShowCreate(true) setShowCreate(true)
setCreateKind(allowRecipe && onCreateRecipe ? null : 'food')
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' })) setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
}}> }}>
Eigenes Lebensmittel anlegen {allowRecipe && onCreateRecipe ? 'Lebensmittel oder Rezept anlegen' : 'Eigenes Lebensmittel anlegen'}
</button> </button>
) : !createKind ? (
<div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Was möchtest du anlegen?</p>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 8px' }}>Lebensmittel = ein Katalogeintrag. Rezept = mehrere Zutaten; offene Zutaten erscheinen danach in der Liste.</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreateKind('food')}>Lebensmittel</button>
<button type="button" className="btn btn-secondary" onClick={() => setCreateKind('recipe')}>Rezept</button>
<button type="button" className="btn btn-secondary" onClick={() => { setShowCreate(false); setCreateKind(null) }}>Zurück</button>
</div>
</div>
) : createKind === 'recipe' ? (
<RecipeForm
defaultName={manual.name_de || q || initialQuery || ''}
learned={learned}
disabled={creating}
submitLabel="Rezept anlegen und zuordnen"
onCancel={() => setCreateKind(null)}
onSave={onCreateRecipe}
/>
) : ( ) : (
<div> <div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag (pro 100 g)</p> <p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag</p>
<input <input
className="form-input" className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
placeholder="Name" placeholder="Name, z. B. Norsan Omega-3 Algenöl"
value={manual.name_de} value={manual.name_de}
onChange={(e) => setManual({ ...manual, name_de: e.target.value })} onChange={(e) => setManual({ ...manual, name_de: e.target.value })}
/> />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <FoodNutrientFields
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => ( macros={manual}
<label key={key} style={{ fontSize: 12, color: 'var(--text2)' }}> setMacros={setManual}
{label} extras={nutrientExtras}
<input setExtras={setNutrientExtras}
className="form-input" servingG={servingG}
type="number" setServingG={setServingG}
min="0"
step="0.1"
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
value={manual[key]}
onChange={(e) => setManual({ ...manual, [key]: e.target.value })}
/> />
</label> <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
))} {allowRecipe && onCreateRecipe && (
</div> <button type="button" className="btn btn-secondary" disabled={creating} onClick={() => setCreateKind(null)}>Zurück</button>
)}
<button <button
type="button" type="button"
className="btn btn-primary btn-full" className="btn btn-primary"
style={{ marginTop: 10 }} style={{ flex: 1 }}
disabled={creating} disabled={creating}
onClick={createManual} onClick={createManual}
> >
{creating ? 'Speichere…' : 'Anlegen und zuordnen'} {creating ? 'Speichere…' : 'Anlegen und zuordnen'}
</button> </button>
</div> </div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal' import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeEditor, { RecipeForm } from './FoodRecipeEditor'
const PERIODS = [ const PERIODS = [
{ days: 14, label: '14 Tage' }, { days: 14, label: '14 Tage' },
@ -72,42 +74,62 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
) )
} }
function InlineCreate({ defaultName, disabled, onCreate }) { function InlineCreate({ defaultName, disabled, learned, allowRecipe, onCreateFood, onCreateRecipe }) {
const [kind, setKind] = useState(allowRecipe ? null : 'food')
const [name, setName] = useState(defaultName || '') const [name, setName] = useState(defaultName || '')
const [kcal, setKcal] = useState('0') const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
const [protein, setProtein] = useState('0') const [extras, setExtras] = useState([])
const [fat, setFat] = useState('0') const [servingG, setServingG] = useState('100')
const [carbs, setCarbs] = useState('0') if (!kind) {
return ( return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}> <div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag (Werte pro 100 g, Salz z. B. alles 0)</p> <p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Lebensmittel (ein Eintrag im Katalog) oder Rezept (mehrere Zutaten)?</p>
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" /> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}> <button type="button" className="btn btn-primary" disabled={disabled} onClick={() => setKind('food')}>Lebensmittel</button>
{[['kcal', kcal, setKcal], ['Protein', protein, setProtein], ['Fett', fat, setFat], ['KH', carbs, setCarbs]].map(([label, val, set]) => ( {allowRecipe && (
<label key={label} style={{ fontSize: 11, color: 'var(--text3)' }}> <button type="button" className="btn btn-secondary" disabled={disabled} onClick={() => setKind('recipe')}>Rezept</button>
{label} )}
<input className="form-input" type="number" min="0" step="0.1" style={{ width: '100%', textAlign: 'left', marginTop: 2 }} value={val} onChange={(e) => set(e.target.value)} />
</label>
))}
</div> </div>
</div>
)
}
if (kind === 'recipe') {
return (
<RecipeForm
defaultName={defaultName}
learned={learned}
disabled={disabled}
submitLabel="Rezept anlegen und zuordnen"
onCancel={() => setKind(null)}
onSave={onCreateRecipe}
/>
)
}
return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.</p>
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<FoodNutrientFields
macros={macros}
setMacros={setMacros}
extras={extras}
setExtras={setExtras}
servingG={servingG}
setServingG={setServingG}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={() => setKind(null)}>Zurück</button>
<button <button
type="button" type="button"
className="btn btn-primary btn-full" className="btn btn-primary"
style={{ marginTop: 8 }} style={{ flex: 1 }}
disabled={disabled || !name.trim()} disabled={disabled || !name.trim()}
onClick={() => onCreate({ onClick={() => onCreateFood(foodCreatePayload(name, macros, extras, servingG))}
name_de: name.trim(),
macros_per_100g: {
kcal: parseFloat(String(kcal).replace(',', '.')) || 0,
protein_g: parseFloat(String(protein).replace(',', '.')) || 0,
fat_g: parseFloat(String(fat).replace(',', '.')) || 0,
carbs_g: parseFloat(String(carbs).replace(',', '.')) || 0,
},
})}
> >
Anlegen und zuordnen Anlegen und zuordnen
</button> </button>
</div> </div>
</div>
) )
} }
@ -243,13 +265,33 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
} }
} }
const createRecipeAndAssign = async (row, body) => {
setSaving(row.source_name_raw)
setError(null)
try {
const rec = await api.createNutritionRecipe({ ...body, source: 'manual' })
await api.applyNutritionRecipe(rec.id, row.source_name_raw)
setCreateFor(null)
setSearchFor(null)
setNotice(`Rezept angelegt. Offene Zutaten erscheinen in der Liste.`)
await load()
onMapped?.()
} catch (e) {
setError(e.message)
throw e
} finally {
setSaving(null)
}
}
const applyRecipe = async (sourceName, recipeId) => { const applyRecipe = async (sourceName, recipeId) => {
setSaving(sourceName) setSaving(sourceName)
setError(null) setError(null)
try { try {
await api.applyNutritionRecipe(recipeId, sourceName) await api.applyNutritionRecipe(recipeId, sourceName)
setRecipeFor(null) setRecipeFor(null)
setUnmapped((list) => list.filter((x) => x.source_name_raw !== sourceName)) setNotice('Rezept verknüpft. Offene Zutaten erscheinen in der Liste.')
await load()
onMapped?.() onMapped?.()
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
@ -331,7 +373,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
<button type="button" className="btn btn-secondary btn-full" disabled={importing} onClick={() => listRef.current?.click()}> <button type="button" className="btn btn-secondary btn-full" disabled={importing} onClick={() => listRef.current?.click()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'} {importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
</button> </button>
{recipes.length > 0 && <p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>} <FoodRecipeEditor recipes={recipes} learned={learned} onChanged={() => { load(); onChanged?.() }} />
<input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} /> <input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>Zuordnungen & Listen exportieren</button> <button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>Zuordnungen & Listen exportieren</button>
@ -413,7 +455,7 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}> <button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}>
{createFor === key ? 'Anlegen schließen' : 'Neu anlegen'} {createFor === key ? 'Anlegen schließen' : (u.kind === 'recipe_ingredient' ? 'Neu anlegen' : 'Lebensmittel oder Rezept')}
</button> </button>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button> <button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
@ -424,7 +466,14 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
)} )}
</div> </div>
{createFor === key && ( {createFor === key && (
<InlineCreate defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw} disabled={busyRow} onCreate={(body) => createAndAssign(u, body)} /> <InlineCreate
defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw}
disabled={busyRow}
learned={learned}
allowRecipe={u.kind !== 'recipe_ingredient'}
onCreateFood={(body) => createAndAssign(u, body)}
onCreateRecipe={(body) => createRecipeAndAssign(u, body)}
/>
)} )}
</div> </div>
) )
@ -459,8 +508,11 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
title={`Suchen: ${searchFor.source_name_raw}`} title={`Suchen: ${searchFor.source_name_raw}`}
initialQuery={suggestQuery(searchFor.source_name_raw)} initialQuery={suggestQuery(searchFor.source_name_raw)}
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw} quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
allowRecipe={searchFor.kind !== 'recipe_ingredient'}
learned={learned}
onClose={() => setSearchFor(null)} onClose={() => setSearchFor(null)}
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)} onSelect={(food, extras) => assign(searchFor, food.id, extras, food)}
onCreateRecipe={searchFor.kind === 'recipe_ingredient' ? undefined : (body) => createRecipeAndAssign(searchFor, body)}
/> />
)} )}
{recipeFor && ( {recipeFor && (

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
export default function AdminFoodMappingsPage() { export default function AdminFoodMappingsPage() {
@ -9,6 +9,13 @@ export default function AdminFoodMappingsPage() {
const [hits, setHits] = useState([]) const [hits, setHits] = useState([])
const [foodId, setFoodId] = useState('') const [foodId, setFoodId] = useState('')
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [filter, setFilter] = useState('')
const [editId, setEditId] = useState(null)
const [editQ, setEditQ] = useState('')
const [editHits, setEditHits] = useState([])
const [saving, setSaving] = useState(false)
const searchTimer = useRef(null)
const editTimer = useRef(null)
const load = () => { const load = () => {
Promise.all([api.adminListFoodMappings(false), api.adminFoodMappingCoverage()]) Promise.all([api.adminListFoodMappings(false), api.adminFoodMappingCoverage()])
@ -17,6 +24,37 @@ export default function AdminFoodMappingsPage() {
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
const searchFoods = (term, setter) => {
const q = (term || '').trim()
if (q.length < 2) {
setter([])
return
}
api.searchBlsFoods(q, 20).then(setter).catch(() => setter([]))
}
const shown = rows.filter((r) => {
const term = filter.trim().toLowerCase()
if (!term) return true
const hay = `${r.source_name_raw || ''} ${r.food_name_de || ''} ${r.bls_code || ''}`.toLowerCase()
return hay.includes(term)
})
const applyChange = async (id, food) => {
setSaving(true)
setError(null)
try {
await api.adminUpdateFoodMapping(id, { food_id: food.id })
setEditId(null)
setEditHits([])
load()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
return ( return (
<div className="card"> <div className="card">
<h1 className="page-title">FDDBBLS Mappings</h1> <h1 className="page-title">FDDBBLS Mappings</h1>
@ -31,9 +69,10 @@ export default function AdminFoodMappingsPage() {
<input className="form-input" placeholder="FDDB-Bezeichner" value={name} onChange={(e) => setName(e.target.value)} /> <input className="form-input" placeholder="FDDB-Bezeichner" value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
<div className="form-row"> <div className="form-row">
<input className="form-input" placeholder="Lebensmittel suchen" value={foodQ} onChange={async (e) => { <input className="form-input" placeholder="Lebensmittel suchen" value={foodQ} onChange={(e) => {
setFoodQ(e.target.value) setFoodQ(e.target.value)
if (e.target.value.length > 1) setHits(await api.searchBlsFoods(e.target.value)) clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => searchFoods(e.target.value, setHits), 300)
}} /> }} />
</div> </div>
{hits.map((h) => ( {hits.map((h) => (
@ -48,20 +87,56 @@ export default function AdminFoodMappingsPage() {
onClick={async () => { onClick={async () => {
try { try {
await api.adminCreateFoodMapping({ source_name: name, food_id: foodId }) await api.adminCreateFoodMapping({ source_name: name, food_id: foodId })
setName(''); setFoodId(''); setFoodQ(''); load() setName(''); setFoodId(''); setFoodQ(''); setHits([]); load()
} catch (e) { setError(e.message) } } catch (e) { setError(e.message) }
}} }}
> >
Globales Mapping anlegen Globales Mapping anlegen
</button> </button>
{rows.map((r) => (
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderTop: '1px solid var(--border)' }}> <input className="form-input" style={{ width: '100%', textAlign: 'left', margin: '16px 0 8px' }} placeholder="Liste filtern…" value={filter} onChange={(e) => setFilter(e.target.value)} />
{shown.map((r) => (
<div key={r.id} style={{ padding: '8px 0', borderTop: '1px solid var(--border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
<div> <div>
{r.source_name_raw} {r.bls_code} {r.food_name_de} {r.source_name_raw} {r.bls_code} {r.food_name_de}
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{r.profile_id ? 'user' : 'global'}</div> <div style={{ fontSize: 11, color: 'var(--text3)' }}>{r.profile_id ? 'user' : 'global'}</div>
</div> </div>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn btn-secondary" onClick={() => { setEditId(editId === r.id ? null : r.id); setEditQ(''); setEditHits([]) }}>
{editId === r.id ? 'Schließen' : 'Ändern'}
</button>
<button type="button" className="btn btn-secondary" onClick={async () => { await api.adminDeleteFoodMapping(r.id); load() }}>Löschen</button> <button type="button" className="btn btn-secondary" onClick={async () => { await api.adminDeleteFoodMapping(r.id); load() }}>Löschen</button>
</div> </div>
</div>
{editId === r.id && (
<div style={{ marginTop: 8 }}>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Neues Lebensmittel suchen"
value={editQ}
onChange={(e) => {
setEditQ(e.target.value)
clearTimeout(editTimer.current)
editTimer.current = setTimeout(() => searchFoods(e.target.value, setEditHits), 300)
}}
/>
{editHits.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 6, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
disabled={saving}
onClick={() => applyChange(r.id, h)}
>
{h.name_de}{h.bls_code ? ` · ${h.bls_code}` : ''}
</button>
))}
</div>
)}
</div>
))} ))}
</div> </div>
) )

View File

@ -243,6 +243,9 @@ export const api = {
listUnmappedFoods: (sinceDays=0) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}`), listUnmappedFoods: (sinceDays=0) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}`),
listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`), listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`),
listNutritionRecipes: () => req('/nutrition/recipes'), listNutritionRecipes: () => req('/nutrition/recipes'),
createNutritionRecipe: (d) => req('/nutrition/recipes', json(d)),
updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)),
deleteNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`, {method:'DELETE'}),
importFddbLists: async (file) => { importFddbLists: async (file) => {
const fd = new FormData(); fd.append('file', file) const fd = new FormData(); fd.append('file', file)
const r = await fetch(`${BASE}/nutrition/recipes/import-fddb-lists`, { method: 'POST', body: fd, headers: hdrs() }) const r = await fetch(`${BASE}/nutrition/recipes/import-fddb-lists`, { method: 'POST', body: fd, headers: hdrs() })
@ -253,7 +256,9 @@ export const api = {
const r = await fetch(`${BASE}/nutrition/food-knowledge`, { headers: hdrs() }) const r = await fetch(`${BASE}/nutrition/food-knowledge`, { headers: hdrs() })
if (!r.ok) { if (!r.ok) {
const text = await r.text() const text = await r.text()
throw new Error(text.trim() || `HTTP ${r.status}`) let msg = text.trim() || `HTTP ${r.status}`
try { msg = formatFastApiDetail(JSON.parse(text).detail, msg) } catch { /* HTML/Text */ }
throw new Error(msg)
} }
const blob = await r.blob() const blob = await r.blob()
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
@ -277,6 +282,7 @@ export const api = {
searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}), searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}),
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })), suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
createUserFood: (d) => req('/bls/foods/manual', json(d)), createUserFood: (d) => req('/bls/foods/manual', json(d)),
listFoodAttributes: (q='', limit=40) => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}`),
listMyFoodMappings: () => req('/bls/mappings'), listMyFoodMappings: () => req('/bls/mappings'),
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)), upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}), deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),
@ -295,6 +301,7 @@ export const api = {
adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)), adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)),
adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`), adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),
adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)), adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)),
adminUpdateFoodMapping: (id, d) => req(`/admin/food-mappings/${id}`, jput(d)),
adminDeleteFoodMapping: (id) => req(`/admin/food-mappings/${id}`, {method:'DELETE'}), adminDeleteFoodMapping: (id) => req(`/admin/food-mappings/${id}`, {method:'DELETE'}),
adminFoodMappingCoverage: () => req('/admin/food-mappings/stats/coverage'), adminFoodMappingCoverage: () => req('/admin/food-mappings/stats/coverage'),
listNutrition: (l=365) => req(`/nutrition?limit=${l}`), listNutrition: (l=365) => req(`/nutrition?limit=${l}`),