feat: FDDB-Listen auflösen und Katalog nach Name suchen
All checks were successful
Deploy Development / deploy (push) Successful in 1m9s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s

Tagebuchzeilen eigener Rezepte werden über den Listen-Import in Zutaten zerlegt. Zuordnen erfolgt im Namens-Popup statt per BLS-Code.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-12 15:31:20 +02:00
parent 4b1be3019d
commit 86f15e6957
17 changed files with 866 additions and 86 deletions

View File

@ -6,6 +6,14 @@
Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmittelschlüssel (BLS) 4.0 plus manuelle Katalogerweiterung, lernendes Mapping von FDDB-Bezeichnern, persistierte Tagebuchzeilen. Reine Tagesmakros bleiben First Class. Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmittelschlüssel (BLS) 4.0 plus manuelle Katalogerweiterung, lernendes Mapping von FDDB-Bezeichnern, persistierte Tagebuchzeilen. Reine Tagesmakros bleiben First Class.
## Zuordnung (UX)
Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen.
## FDDB-Listen / eigene Rezepte
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Rezeptname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Rezept verknüpft → **Zutaten** zuordnen, nicht das Rezept als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück.
## Fachliche Regeln ## Fachliche Regeln
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports. - BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.

View File

@ -1,6 +1,6 @@
# BLS Food Reference technische Spec # BLS Food Reference technische Spec
**Stand:** 2026-09-12 · Migration **062** **Stand:** 2026-09-12 · Migration **062** + **063**
## Tabellen ## Tabellen
@ -11,11 +11,14 @@
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at` - `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute - `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
- `nutrition_day_marks``fasting` | `incomplete` - `nutrition_day_marks``fasting` | `incomplete`
- `food_recipes` / `food_recipe_ingredients` — FDDB-Listen; `nutrition_items.recipe_id`
## Layer 1 ## Layer 1
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete - `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes` - `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Rezept-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen)
- `bls/recipe_parser.py``produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben)
## Import ## Import
@ -29,3 +32,6 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
- `/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`
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping
- Frontend: `FoodSearchModal` (Name-Suche), Listen-Import auf dem Tab Zuordnen

View File

@ -121,7 +121,7 @@ frontend/src/
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil. - **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings. - **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen; Fasten/Lücke; Import-Abgleich. - **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; Fasten/Lücke; Import-Abgleich.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.

View File

@ -0,0 +1,79 @@
"""Parse FDDB lists_*.csv (name;…;produkte) into recipe + ingredients."""
from __future__ import annotations
import csv
import io
import re
from typing import Any
from data_layer.food_mapping import normalize_food_name, parse_quantity_g
ING_RE = re.compile(
r"(?P<qty>\d+(?:[.,]\d+)?)\s*(?P<unit>g|kg|ml|l)\b\s*(?P<name>.+?)"
r"(?=,\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l)\b|$)",
re.IGNORECASE | re.DOTALL,
)
def parse_fddb_produkte(text: str) -> list[dict[str, Any]]:
raw = (text or "").strip().strip('"')
if not raw:
return []
out: list[dict[str, Any]] = []
for i, m in enumerate(ING_RE.finditer(raw)):
qty = float(m.group("qty").replace(",", "."))
unit = m.group("unit").lower()
name = re.sub(r"\s+", " ", m.group("name")).strip(" ,;")
if not name:
continue
grams = qty
if unit == "kg":
grams = qty * 1000.0
elif unit == "l":
grams = qty * 1000.0
elif unit == "ml":
grams = qty
out.append({
"source_name_raw": name,
"source_name_normalized": normalize_food_name(name),
"quantity_raw": f"{m.group('qty').replace(',', '.')} {unit}",
"quantity_g": round(grams, 3),
"sort_order": i,
})
return out
def parse_fddb_lists_csv(text: str) -> list[dict[str, Any]]:
if text.startswith("\ufeff"):
text = text[1:]
reader = csv.DictReader(io.StringIO(text), delimiter=";")
recipes = []
for row in reader:
name = (row.get("name") or "").strip().strip('"')
if not name:
continue
try:
portions = float(str(row.get("anzahl_portionen") or "1").replace(",", "."))
except ValueError:
portions = 1.0
if portions <= 0:
portions = 1.0
ingredients = parse_fddb_produkte(row.get("produkte") or "")
if not ingredients:
leftover = (row.get("produkte") or "").strip().strip('"')
if leftover:
ingredients = [{
"source_name_raw": leftover,
"source_name_normalized": normalize_food_name(leftover),
"quantity_raw": None,
"quantity_g": parse_quantity_g(leftover),
"sort_order": 0,
}]
recipes.append({
"name_raw": name,
"name_normalized": normalize_food_name(name),
"portions": portions,
"description": (row.get("beschreibung") or "").strip() or None,
"ingredients": ingredients,
})
return recipes

View File

@ -17,6 +17,7 @@ def normalize_food_name(raw: str | None) -> str:
if not raw: if not raw:
return "" return ""
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'") s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
s = s.lstrip("!")
s = LEADING_QTY_RE.sub("", s) s = LEADING_QTY_RE.sub("", s)
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s) s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
s = MULTISPACE_RE.sub(" ", s).strip().lower() s = MULTISPACE_RE.sub(" ", s).strip().lower()
@ -133,6 +134,7 @@ def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, fo
UPDATE nutrition_items UPDATE nutrition_items
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW() SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s WHERE profile_id = %s AND source_name_normalized = %s
AND recipe_id IS NULL
""", """,
(food_id, mapping_id, origin, profile_id, source_name_normalized), (food_id, mapping_id, origin, profile_id, source_name_normalized),
) )
@ -166,8 +168,11 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int =
q = (query or "").strip() q = (query or "").strip()
if not q: if not q:
return [] return []
like = f"%{q}%" primary = q.split(",")[0].strip() or q
norm = normalize_food_name(q) like_full = f"%{q}%"
like_primary = f"%{primary}%"
prefix = f"{primary}%"
norm = normalize_food_name(primary)
cur.execute( cur.execute(
""" """
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
@ -178,17 +183,24 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int =
OR owner_profile_id = %s OR owner_profile_id = %s
) )
AND ( AND (
name_de ILIKE %s OR COALESCE(name_en, '') ILIKE %s name_de ILIKE %s OR name_de 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
) )
ORDER BY ORDER BY
CASE WHEN lower(name_de) = %s THEN 0 CASE
WHEN COALESCE(bls_code, '') ILIKE %s THEN 1 WHEN lower(name_de) = %s THEN 0
ELSE 2 END, WHEN name_de ILIKE %s THEN 1
WHEN COALESCE(bls_code, '') ILIKE %s THEN 2
ELSE 3 END,
name_de name_de
LIMIT %s LIMIT %s
""", """,
(profile_id, like, like, like, norm, norm, q, limit), (
profile_id,
like_full, like_primary, like_full, like_primary, like_full, norm,
norm, prefix, q, limit,
),
) )
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]

View File

@ -0,0 +1,192 @@
"""FDDB recipe lists: upsert, link to diary items, catalog macros via ingredients."""
from __future__ import annotations
import uuid
from typing import Any
from data_layer.food_mapping import get_food_mapping_with_cursor, normalize_food_name
from data_layer.nutrition_items import catalog_macros_for_item, _f
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
inserted = updated = ingredients = 0
for rec in recipes:
norm = rec.get("name_normalized") or normalize_food_name(rec.get("name_raw"))
if not norm:
continue
cur.execute(
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
(profile_id, norm),
)
row = cur.fetchone()
if row:
rid = row["id"]
cur.execute(
"""
UPDATE food_recipes
SET name_raw=%s, portions=%s, description=%s, updated_at=NOW()
WHERE id=%s
""",
(rec["name_raw"], rec.get("portions") or 1, rec.get("description"), rid),
)
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (rid,))
updated += 1
else:
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,'fddb_list')
""",
(rid, profile_id, rec["name_raw"], norm, rec.get("portions") or 1, rec.get("description")),
)
inserted += 1
for ing in rec.get("ingredients") or []:
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
if not inorm:
continue
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, ing["source_name_raw"], inorm,
ing.get("quantity_raw"), ing.get("quantity_g"), ing.get("sort_order") or 0,
),
)
ingredients += 1
linked, dates = link_recipes_to_items(cur, profile_id)
return {
"inserted": inserted,
"updated": updated,
"ingredients": ingredients,
"items_linked": linked,
"dates_linked": dates,
}
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
cur.execute(
"""
SELECT DISTINCT i.date::text AS date
FROM nutrition_items i
JOIN food_recipes r ON r.profile_id = i.profile_id
AND i.source_name_normalized = r.name_normalized
WHERE i.profile_id = %s AND i.food_id IS NULL
""",
(profile_id,),
)
dates = [r["date"] for r in cur.fetchall()]
cur.execute(
"""
UPDATE nutrition_items i
SET recipe_id = r.id, updated_at = NOW()
FROM food_recipes r
WHERE i.profile_id = %s AND r.profile_id = %s
AND i.source_name_normalized = r.name_normalized
AND i.food_id IS NULL
""",
(profile_id, profile_id),
)
return cur.rowcount or 0, dates
def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT id, name_raw, name_normalized, portions, description, source
FROM food_recipes
WHERE profile_id = %s
ORDER BY name_normalized
""",
(profile_id,),
)
recipes = [dict(r) for r in cur.fetchall()]
if not recipes:
return []
ids = [r["id"] for r in recipes]
cur.execute(
"""
SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order
FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s)
ORDER BY sort_order, source_name_raw
""",
(ids,),
)
by_r: dict[str, list] = {str(i): [] for i in ids}
for row in cur.fetchall():
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
for rec in recipes:
rec["ingredients"] = by_r.get(str(rec["id"]), [])
return recipes
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
cur.execute(
"""
UPDATE nutrition_items
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s
""",
(recipe_id, profile_id, source_name_normalized),
)
return cur.rowcount or 0
def mapped_ingredient_quantities(
cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None
) -> list[dict[str, Any]] | None:
cur.execute(
"SELECT portions FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
rec = cur.fetchone()
if not rec:
return None
cur.execute(
"""
SELECT source_name_raw, quantity_g
FROM food_recipe_ingredients
WHERE recipe_id = %s
ORDER BY sort_order
""",
(recipe_id,),
)
ings = cur.fetchall()
if not ings:
return None
total_g = sum(_f(i.get("quantity_g")) for i in ings)
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
scale = float(eaten_qty_g) / total_g
else:
portions = float(rec.get("portions") or 1) or 1.0
scale = 1.0 / portions
out = []
for ing in ings:
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
if not mapping:
return None
out.append({
"food_id": mapping["food_id"],
"quantity_g": _f(ing.get("quantity_g")) * scale,
})
return out
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
parts = mapped_ingredient_quantities(cur, profile_id, recipe_id, eaten_qty_g)
if not parts:
return None
acc = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
for part in parts:
cat = catalog_macros_for_item(cur, part["food_id"], part["quantity_g"])
if not cat:
return None
for k in acc:
acc[k] += cat[k]
return acc

View File

@ -99,7 +99,7 @@ def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, A
) )
cur.execute( cur.execute(
""" """
SELECT food_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin SELECT food_id, recipe_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin
FROM nutrition_items FROM nutrition_items
WHERE profile_id = %s AND date = %s WHERE profile_id = %s AND date = %s
""", """,
@ -115,6 +115,10 @@ def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, A
fddb["protein_g"] += _f(it.get("fddb_protein_g")) fddb["protein_g"] += _f(it.get("fddb_protein_g"))
fddb["fat_g"] += _f(it.get("fddb_fat_g")) fddb["fat_g"] += _f(it.get("fddb_fat_g"))
fddb["carbs_g"] += _f(it.get("fddb_carbs_g")) fddb["carbs_g"] += _f(it.get("fddb_carbs_g"))
if it.get("recipe_id") and not it.get("food_id"):
from data_layer.food_recipes import catalog_macros_for_recipe
cat = catalog_macros_for_recipe(cur, profile_id, it["recipe_id"], it.get("quantity_g"))
else:
cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g")) cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g"))
if cat: if cat:
mapped += 1 mapped += 1
@ -200,22 +204,9 @@ def apply_nutrition_day_macros(
return "created" return "created"
def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None: def _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantity_g: float) -> None:
cur.execute( if not food_id or not quantity_g or quantity_g <= 0:
"DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s", return
(profile_id, day),
)
cur.execute(
"""
SELECT i.food_id, i.quantity_g
FROM nutrition_items i
WHERE i.profile_id = %s AND i.date = %s
AND i.food_id IS NOT NULL AND i.quantity_g IS NOT NULL AND i.quantity_g > 0
""",
(profile_id, day),
)
acc: dict[int, list[float]] = {}
for it in cur.fetchall():
cur.execute( cur.execute(
""" """
SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type
@ -224,11 +215,38 @@ def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
WHERE v.food_id = %s AND a.data_type = 'num_per_100g' 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 v.value_num IS NOT NULL AND v.is_trace = false
""", """,
(it["food_id"],), (food_id,),
) )
factor = float(it["quantity_g"]) / 100.0 factor = float(quantity_g) / 100.0
for row in cur.fetchall(): for row in cur.fetchall():
acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor) acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
cur.execute(
"DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s",
(profile_id, day),
)
cur.execute(
"""
SELECT food_id, recipe_id, quantity_g
FROM nutrition_items
WHERE profile_id = %s AND date = %s
""",
(profile_id, day),
)
acc: dict[int, list[float]] = {}
from data_layer.food_recipes import mapped_ingredient_quantities
for it in cur.fetchall():
if it.get("food_id"):
_accumulate_food_qty(cur, acc, it["food_id"], _f(it.get("quantity_g")))
continue
if it.get("recipe_id"):
parts = mapped_ingredient_quantities(cur, profile_id, it["recipe_id"], it.get("quantity_g"))
if not parts:
continue
for part in parts:
_accumulate_food_qty(cur, acc, part["food_id"], part["quantity_g"])
for attr_id, vals in acc.items(): for attr_id, vals in acc.items():
cur.execute( cur.execute(
""" """
@ -302,9 +320,9 @@ def replace_csv_items_for_dates(
id, profile_id, date, logged_at, source_name_raw, source_name_normalized, id, profile_id, date, logged_at, source_name_raw, source_name_normalized,
source_system, quantity_raw, quantity_g, source_system, quantity_raw, quantity_g,
fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g,
food_id, mapping_id, value_origin, source food_id, mapping_id, value_origin, source, recipe_id
) VALUES ( ) VALUES (
%s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv' %s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s
) )
""", """,
( (
@ -323,9 +341,12 @@ def replace_csv_items_for_dates(
mapping["food_id"] if mapping else None, mapping["food_id"] if mapping else None,
mapping["mapping_id"] if mapping else None, mapping["mapping_id"] if mapping else None,
_item_value_origin(mapping), _item_value_origin(mapping),
None,
), ),
) )
items_written += 1 items_written += 1
from data_layer.food_recipes import link_recipes_to_items
link_recipes_to_items(cur, profile_id)
days_written += 1 days_written += 1
sums = compute_day_macro_sums(cur, profile_id, iso) sums = compute_day_macro_sums(cur, profile_id, iso)
rebuild_daily_nutrients(cur, profile_id, iso) rebuild_daily_nutrients(cur, profile_id, iso)
@ -431,7 +452,12 @@ def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str)
SELECT DISTINCT date::text AS date SELECT DISTINCT date::text AS date
FROM nutrition_items FROM nutrition_items
WHERE profile_id = %s AND source_name_normalized = %s WHERE profile_id = %s AND source_name_normalized = %s
UNION
SELECT DISTINCT i.date::text AS date
FROM nutrition_items i
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
WHERE i.profile_id = %s AND ri.source_name_normalized = %s
""", """,
(profile_id, source_name_normalized), (profile_id, source_name_normalized, profile_id, source_name_normalized),
) )
return [r["date"] for r in cur.fetchall()] return [r["date"] for r in cur.fetchall()]

View File

@ -0,0 +1,43 @@
-- Migration 063: FDDB-Listen/Rezepte + Verknüpfung an nutrition_items
CREATE TABLE IF NOT EXISTS food_recipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name_raw VARCHAR(500) NOT NULL,
name_normalized VARCHAR(500) NOT NULL,
portions NUMERIC(8,2) NOT NULL DEFAULT 1,
description TEXT,
source VARCHAR(20) NOT NULL DEFAULT 'fddb_list',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_food_recipe_profile_name UNIQUE (profile_id, name_normalized)
);
CREATE INDEX IF NOT EXISTS idx_food_recipes_profile ON food_recipes (profile_id);
CREATE TABLE IF NOT EXISTS food_recipe_ingredients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recipe_id UUID NOT NULL REFERENCES food_recipes(id) ON DELETE CASCADE,
source_name_raw VARCHAR(500) NOT NULL,
source_name_normalized VARCHAR(500) NOT NULL,
quantity_raw VARCHAR(80),
quantity_g NUMERIC(10,3),
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_food_recipe_ing_recipe ON food_recipe_ingredients (recipe_id);
CREATE INDEX IF NOT EXISTS idx_food_recipe_ing_norm ON food_recipe_ingredients (source_name_normalized);
ALTER TABLE nutrition_items
ADD COLUMN IF NOT EXISTS recipe_id UUID REFERENCES food_recipes(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_nutrition_items_recipe
ON nutrition_items (recipe_id)
WHERE recipe_id IS NOT NULL;
COMMENT ON TABLE food_recipes IS 'FDDB-Listen/Rezepte; Tagebuchzeile kann statt Einzel-BLS auf ein Rezept zeigen';
DO $$
BEGIN
RAISE NOTICE 'Migration 063: FDDB recipes + nutrition_items.recipe_id';
END $$;

View File

@ -354,16 +354,109 @@ def list_unmapped_foods(
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
""" """
SELECT source_name_raw, source_name_normalized, SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, MIN(date) AS first_date, MAX(date) AS last_date COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
FROM nutrition_items MIN(r.id::text) AS matching_recipe_id
WHERE profile_id=%s AND food_id IS NULL FROM nutrition_items i
GROUP BY source_name_raw, source_name_normalized LEFT JOIN food_recipes r
ORDER BY count DESC, source_name_normalized ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
WHERE i.profile_id=%s AND i.food_id IS NULL AND i.recipe_id IS NULL
GROUP BY i.source_name_raw, i.source_name_normalized
ORDER BY count DESC, i.source_name_normalized
""", """,
(pid,), (pid,),
) )
return [r2d(r) for r in cur.fetchall()] diary = [r2d(r) | {"kind": "diary"} for r in cur.fetchall()]
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date
FROM food_recipe_ingredients i
JOIN food_recipes r ON r.id = i.recipe_id
LEFT JOIN food_name_mappings m
ON m.profile_id = r.profile_id
AND m.source_name_normalized = i.source_name_normalized
WHERE r.profile_id = %s AND m.id IS NULL
GROUP BY i.source_name_raw, i.source_name_normalized
ORDER BY count DESC, i.source_name_normalized
""",
(pid,),
)
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
seen = {d["source_name_normalized"] for d in diary}
for ing in ings:
if ing["source_name_normalized"] not in seen:
diary.append(ing)
seen.add(ing["source_name_normalized"])
diary.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
return diary
@router.get("/recipes")
def list_food_recipes(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import list_recipes
pid = get_pid(x_profile_id)
with get_db() as conn:
return list_recipes(get_cursor(conn), pid)
@router.post("/recipes/import-fddb-lists")
async def import_fddb_lists(
file: UploadFile = File(...),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from bls.recipe_parser import parse_fddb_lists_csv
from data_layer.food_recipes import upsert_recipes
pid = get_pid(x_profile_id)
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError:
text = raw.decode("latin-1")
recipes = parse_fddb_lists_csv(text)
if not recipes:
raise HTTPException(400, "Keine Rezepte in der Datei erkannt")
with get_db() as conn:
cur = get_cursor(conn)
stats = upsert_recipes(cur, pid, recipes)
from data_layer.nutrition_items import rebuild_daily_nutrients
for d in stats.pop("dates_linked", []) or []:
rebuild_daily_nutrients(cur, pid, d)
return {"ok": True, "recipes": len(recipes), **stats}
@router.post("/recipes/{recipe_id}/apply")
def apply_recipe_name(
recipe_id: str,
body: dict,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_mapping import normalize_food_name
from data_layer.food_recipes import apply_recipe_to_items
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
pid = get_pid(x_profile_id)
source_name = (body.get("source_name") or "").strip()
if not source_name:
raise HTTPException(400, "source_name fehlt")
norm = normalize_food_name(source_name)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s", (recipe_id, pid))
if not cur.fetchone():
raise HTTPException(404, "Rezept nicht gefunden")
n = apply_recipe_to_items(cur, pid, norm, recipe_id)
for d in dates_for_normalized_name(cur, pid, norm):
rebuild_daily_nutrients(cur, pid, d)
return {"ok": True, "items_updated": n}
@router.post("/import-conflicts/resolve") @router.post("/import-conflicts/resolve")

View File

@ -0,0 +1,43 @@
from bls.recipe_parser import parse_fddb_lists_csv, parse_fddb_produkte
from data_layer.food_mapping import normalize_food_name
PORRIDGE = (
"160 g Apfel, Braeburn, 5 ml Omega-3 Vegan Algenöl, 100 g Wildheidelbeeren, "
"30 g Haferflocken, 100% Hafer-Vollkorn, 40 g Hafer Flocken, Großblatt, 11 g Flohsamenschalen"
)
def test_parse_fddb_produkte_splits_on_next_quantity_not_commas():
ings = parse_fddb_produkte(PORRIDGE)
names = [i["source_name_raw"] for i in ings]
assert names == [
"Apfel, Braeburn",
"Omega-3 Vegan Algenöl",
"Wildheidelbeeren",
"Haferflocken, 100% Hafer-Vollkorn",
"Hafer Flocken, Großblatt",
"Flohsamenschalen",
]
assert ings[0]["quantity_g"] == 160.0
assert ings[1]["quantity_g"] == 5.0
assert ings[1]["quantity_raw"] == "5 ml"
def test_parse_fddb_lists_csv_porridge_and_portions():
text = (
"name;beschreibung;anzahl_portionen;zeit_vorbereitung;zeit_kochen;produkte;\n"
'"!PorridgeBreakfast ";"";"1";"0";"0";"' + PORRIDGE + '";\n'
'"Aloo gobi";"";"4";"0";"0";"32 ml Rapso Rapsöl, 83 g Zwiebel, frisch";\n'
)
recipes = parse_fddb_lists_csv(text)
assert len(recipes) == 2
assert recipes[0]["name_raw"] == "!PorridgeBreakfast"
assert recipes[0]["name_normalized"] == normalize_food_name("PorridgeBreakfast")
assert len(recipes[0]["ingredients"]) == 6
assert recipes[1]["portions"] == 4.0
assert recipes[1]["ingredients"][0]["source_name_raw"] == "Rapso Rapsöl"
def test_normalize_strips_list_bang_prefix():
assert normalize_food_name("!PorridgeBreakfast") == normalize_food_name("PorridgeBreakfast")

View File

@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
APP_VERSION = "0.9v" APP_VERSION = "0.9v"
BUILD_DATE = "2026-09-12" BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260912" # 062 BLS catalog + nutrition items/marks DB_SCHEMA_VERSION = "20260912" # 063 FDDB recipes
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.0", "auth": "1.2.0",
@ -20,7 +20,7 @@ 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.1.0", # BLS mapping, items, day marks, import policy "nutrition": "1.2.0", # FDDB-Listen/Rezepte + Katalog-Suchpopup
"bls": "1.0.1", "bls": "1.0.1",
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
@ -45,6 +45,8 @@ CHANGELOG = [
"Lernendes FDDB-Mapping ohne KI, änder- und löschbar", "Lernendes FDDB-Mapping ohne KI, änder- und löschbar",
"Optionale nutrition_items, Import-Policy, Fasten-/Lücken-Marken", "Optionale nutrition_items, Import-Policy, Fasten-/Lücken-Marken",
"BLS-Import als Hintergrundjob (kein Proxy-504)", "BLS-Import als Hintergrundjob (kein Proxy-504)",
"Zuordnen: Katalog-Suche nach Name (Popup), nicht nach BLS-Code",
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen",
], ],
}, },
{ {

View File

@ -14,3 +14,5 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
- FDDB-Import speichert Zeilen; Makro-Konflikt laut Policy - FDDB-Import speichert Zeilen; Makro-Konflikt laut Policy
- Fasten-/Lücken-Marken unabhängig vom Import - Fasten-/Lücken-Marken unabhängig vom Import
- Einzelerfassung nur Makros unverändert - Einzelerfassung nur Makros unverändert
- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer)
- FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen

View File

@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
export default function FoodSearchModal({ title, initialQuery, onSelect, onClose }) {
const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const inputRef = useRef(null)
const timer = useRef(null)
const runSearch = async (term) => {
const query = (term || '').trim()
if (query.length < 2) {
setHits([])
return
}
setLoading(true)
setError(null)
try {
setHits(await api.searchBlsFoods(query, 30))
} catch (e) {
setError(e.message)
setHits([])
} finally {
setLoading(false)
}
}
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
if ((initialQuery || '').trim().length >= 2) runSearch(initialQuery)
const onKey = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
clearTimeout(timer.current)
}
}, [])
const onChange = (value) => {
setQ(value)
clearTimeout(timer.current)
timer.current = setTimeout(() => runSearch(value), 250)
}
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000,
padding: 16,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="food-search-title"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 640px)',
background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<h2 id="food-search-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Lebensmittel suchen'}</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<input
ref={inputRef}
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Name eingeben, z. B. Haferflocken"
value={q}
onChange={(e) => onChange(e.target.value)}
/>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
Suche nach dem Namen. Den BLS-Code brauchst du nicht.
</p>
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{loading && <p style={{ fontSize: 13, color: 'var(--text2)' }}>Suche</p>}
{error && <p style={{ color: 'var(--danger)', fontSize: 13 }}>{error}</p>}
{!loading && q.trim().length >= 2 && hits.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer. Anderen Suchbegriff versuchen.</p>
)}
{hits.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h)}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
</span>
</span>
</button>
))}
</div>
</div>
</div>
)
}

View File

@ -1,19 +1,101 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
function suggestQuery(raw) {
let s = (raw || '').replace(/^\s*[!]?\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim()
s = s.replace(/^!+/, '').trim()
const comma = s.indexOf(',')
if (comma > 2) s = s.slice(0, comma).trim()
return s
}
function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
const [q, setQ] = useState(suggestQuery(sourceName))
const filtered = recipes.filter((r) => {
const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase()
return !q.trim() || hay.includes(q.trim().toLowerCase())
})
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000, padding: 16,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 480, maxHeight: 'min(88vh, 560px)',
background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Eigenes Rezept wählen</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
autoFocus
placeholder="Rezeptname filtern"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept. Zuerst Listen-CSV importieren.</p>}
{filtered.map((r) => (
<button
key={r.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onPick(r.id)}
>
<span>
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{(r.ingredients || []).length} Zutaten
{r.portions ? ` · ${r.portions} Portionen` : ''}
</span>
</span>
</button>
))}
</div>
</div>
</div>
)
}
export default function NutritionFoodMap({ onChanged }) { export default function NutritionFoodMap({ onChanged }) {
const [unmapped, setUnmapped] = useState([]) const [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([]) const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([])
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [query, setQuery] = useState({}) const [notice, setNotice] = useState(null)
const [hits, setHits] = useState({})
const [saving, setSaving] = useState(null) const [saving, setSaving] = useState(null)
const [searchFor, setSearchFor] = useState(null)
const [recipeFor, setRecipeFor] = useState(null)
const [importing, setImporting] = useState(false)
const listRef = useRef(null)
const load = async () => { const load = async () => {
try { try {
const [u, m] = await Promise.all([api.listUnmappedFoods(), api.listMyFoodMappings()]) const [u, m, r] = await Promise.all([
api.listUnmappedFoods(),
api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []),
])
setUnmapped(u) setUnmapped(u)
setLearned(m) setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} }
@ -21,26 +103,27 @@ export default function NutritionFoodMap({ onChanged }) {
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
const search = async (key, q) => { const assign = async (sourceName, foodId) => {
setQuery((s) => ({ ...s, [key]: q })) setSaving(sourceName)
if (!q || q.length < 2) {
setHits((s) => ({ ...s, [key]: [] }))
return
}
try {
const rows = await api.searchBlsFoods(q)
setHits((s) => ({ ...s, [key]: rows }))
} catch (e) {
setError(e.message)
}
}
const assign = async (sourceName, foodId, key) => {
setSaving(key)
setError(null) setError(null)
try { try {
await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId }) await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId })
setHits((s) => ({ ...s, [key]: [] })) setSearchFor(null)
await load()
onChanged?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const applyRecipe = async (sourceName, recipeId) => {
setSaving(sourceName)
setError(null)
try {
await api.applyNutritionRecipe(recipeId, sourceName)
setRecipeFor(null)
await load() await load()
onChanged?.() onChanged?.()
} catch (e) { } catch (e) {
@ -61,45 +144,97 @@ export default function NutritionFoodMap({ onChanged }) {
} }
} }
const importLists = async (file) => {
if (!file) return
setImporting(true)
setError(null)
setNotice(null)
try {
const res = await api.importFddbLists(file)
await load()
onChanged?.()
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen als Rezept verknüpft. Offene Zeilen sind jetzt die Zutaten.`)
} catch (e) {
setError(e.message)
} finally {
setImporting(false)
}
}
return ( return (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Lebensmittel zuordnen</div> <div className="card-title">Lebensmittel zuordnen</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}> <p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
Einmal bestätigt, bleibt die Zuordnung erhalten und gilt für spätere Importe. Tippe auf Im Katalog suchen du suchst nach dem Namen, nicht nach einem Code.
Du kannst sie jederzeit ändern oder löschen. Keine automatische KI-Zuordnung. Eigene FDDB-Listen zuerst importieren, dann wird die Tagebuchzeile in Zutaten aufgelöst.
</p> </p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>} {error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
<h3 style={{ fontSize: 14, margin: '12px 0 8px' }}>Offen ({unmapped.length})</h3> <input
{unmapped.length === 0 && <p className="muted">Keine ungemappten Bezeichner.</p>} ref={listRef}
type="file"
accept=".csv,text/csv"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (f) importLists(f)
}}
/>
<button
type="button"
className="btn btn-secondary btn-full"
disabled={importing}
onClick={() => listRef.current?.click()}
>
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
</button>
{recipes.length > 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>
)}
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Offen ({unmapped.length})</h3>
{unmapped.length === 0 && <p className="muted">Keine offenen Bezeichner.</p>}
{unmapped.map((u) => { {unmapped.map((u) => {
const key = u.source_name_normalized const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
return ( return (
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}> <div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ fontWeight: 600 }}>{u.source_name_raw}</div> <div style={{ fontWeight: 600 }}>{u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}> <div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.count}× · {u.first_date} {u.last_date} {u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
{u.first_date ? ` · ${u.first_date} ${u.last_date}` : ''}
</div> </div>
<input <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
className="form-input" <button
style={{ marginTop: 6 }} type="button"
placeholder="BLS-Code oder Name suchen…" className="btn btn-primary"
value={query[key] || ''} disabled={saving === u.source_name_raw}
onChange={(e) => search(key, e.target.value)} onClick={() => setSearchFor(u)}
/> >
{(hits[key] || []).map((h) => ( Im Katalog suchen
</button>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button <button
key={h.id}
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ marginTop: 6, marginRight: 6 }} disabled={saving === u.source_name_raw}
disabled={saving === key} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}
onClick={() => assign(u.source_name_raw, h.id, key)}
> >
{h.bls_code ? `${h.bls_code} · ` : ''}{h.name_de} Als eigenes Rezept auflösen
{h.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
</button> </button>
))} )}
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
<button
type="button"
className="btn btn-secondary"
disabled={saving === u.source_name_raw}
onClick={() => setRecipeFor(u)}
>
Eigenes Rezept wählen
</button>
)}
</div>
</div> </div>
) )
})} })}
@ -110,13 +245,30 @@ export default function NutritionFoodMap({ onChanged }) {
<div> <div>
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div> <div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}> <div style={{ fontSize: 12, color: 'var(--text3)' }}>
{m.bls_code ? `${m.bls_code} · ` : ''}{m.food_name_de} {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''} {m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
</div> </div>
</div> </div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button> <button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
</div> </div>
))} ))}
{searchFor && (
<FoodSearchModal
title={`Suchen: ${searchFor.source_name_raw}`}
initialQuery={suggestQuery(searchFor.source_name_raw)}
onClose={() => setSearchFor(null)}
onSelect={(food) => assign(searchFor.source_name_raw, food.id)}
/>
)}
{recipeFor && (
<RecipePickModal
recipes={recipes}
sourceName={recipeFor.source_name_raw}
onClose={() => setRecipeFor(null)}
onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)}
/>
)}
</div> </div>
) )
} }
@ -132,7 +284,7 @@ export function DayMarkButtons({ date, markType, onChanged }) {
} }
} }
return ( return (
<span style={{ display: 'inline-flex', gap: 4 }}> <span style={{ display: 'inline-flex', gap: 8 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('fasting')}> <button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('fasting')}>
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'} {markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
</button> </button>

View File

@ -934,7 +934,7 @@ export default function NutritionPage() {
<h1 className="page-title">Ernährung</h1> <h1 className="page-title">Ernährung</h1>
{unmappedCount > 0 && ( {unmappedCount > 0 && (
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}> <div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
{unmappedCount} Lebensmittel noch ohne BLS-Zuordnung.{' '} {unmappedCount} Lebensmittel noch ohne Katalog-Zuordnung.{' '}
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}> <button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
Jetzt zuordnen Jetzt zuordnen
</button> </button>

View File

@ -241,6 +241,13 @@ export const api = {
}, },
listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'), listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'),
listUnmappedFoods: () => req('/nutrition/unmapped'), listUnmappedFoods: () => req('/nutrition/unmapped'),
listNutritionRecipes: () => req('/nutrition/recipes'),
importFddbLists: async (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() })
return readJsonResponse(r)
},
applyNutritionRecipe: (recipeId, sourceName) => req(`/nutrition/recipes/${recipeId}/apply`, json({ source_name: sourceName })),
listNutritionMarks: () => req('/nutrition/marks'), listNutritionMarks: () => req('/nutrition/marks'),
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)), putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}), deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),

View File

@ -65,6 +65,7 @@ test('FEATURE: Ernährung — Einzelerfassung, Import-Policy-Hinweis, Zuordnen',
await expect(page.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible(); await expect(page.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible();
await page.getByRole('button', { name: /Zuordnen/i }).click(); await page.getByRole('button', { name: /Zuordnen/i }).click();
await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible(); await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible();
await expect(page.getByRole('button', { name: /FDDB-Listen/ })).toBeVisible();
}); });
test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => { test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => {