feat: Rezepte beim Zuordnen anlegen und offene Zutaten listen
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:
parent
132a364a3a
commit
a7e5511039
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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**)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
128
backend/data_layer/food_attributes.py
Normal file
128
backend/data_layer/food_attributes.py
Normal 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()}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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]]:
|
||||||
|
|
|
||||||
13
backend/migrations/065_food_attribute_omega_extensions.sql
Normal file
13
backend/migrations/065_food_attribute_omega_extensions.sql
Normal 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;
|
||||||
|
|
@ -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])),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
19
backend/tests/test_food_attributes.py
Normal file
19
backend/tests/test_food_attributes.py
Normal 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
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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%"
|
||||||
|
|
|
||||||
10
backend/tests/test_food_recipes.py
Normal file
10
backend/tests/test_food_recipes.py
Normal 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")
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
142
frontend/src/components/FoodNutrientFields.jsx
Normal file
142
frontend/src/components/FoodNutrientFields.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
frontend/src/components/FoodRecipeEditor.jsx
Normal file
183
frontend/src/components/FoodRecipeEditor.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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">FDDB→BLS Mappings</h1>
|
<h1 className="page-title">FDDB→BLS 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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}`),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user