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.
## 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
- 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
**Stand:** 2026-09-12 · Migration **062**
**Stand:** 2026-09-12 · Migration **062** + **063**
## Tabellen
@ -11,11 +11,14 @@
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
- `nutrition_day_marks``fasting` | `incomplete`
- `food_recipes` / `food_recipe_ingredients` — FDDB-Listen; `nutrition_items.recipe_id`
## Layer 1
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
- `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
@ -29,3 +32,6 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
- `/api/admin/bls/*` — Import, Katalog, Attribute
- `/api/admin/food-mappings` — Admin-CRUD
- `/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.
- **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
- **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:
return ""
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
s = s.lstrip("!")
s = LEADING_QTY_RE.sub("", s)
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
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
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s
AND recipe_id IS NULL
""",
(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()
if not q:
return []
like = f"%{q}%"
norm = normalize_food_name(q)
primary = q.split(",")[0].strip() or q
like_full = f"%{q}%"
like_primary = f"%{primary}%"
prefix = f"{primary}%"
norm = normalize_food_name(primary)
cur.execute(
"""
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
)
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 lower(name_de) = %s
)
ORDER BY
CASE WHEN lower(name_de) = %s THEN 0
WHEN COALESCE(bls_code, '') ILIKE %s THEN 1
ELSE 2 END,
CASE
WHEN lower(name_de) = %s THEN 0
WHEN name_de ILIKE %s THEN 1
WHEN COALESCE(bls_code, '') ILIKE %s THEN 2
ELSE 3 END,
name_de
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()]

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(
"""
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
WHERE profile_id = %s AND date = %s
""",
@ -115,7 +115,11 @@ 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["fat_g"] += _f(it.get("fddb_fat_g"))
fddb["carbs_g"] += _f(it.get("fddb_carbs_g"))
cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_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"))
if cat:
mapped += 1
used_bls = True
@ -200,6 +204,24 @@ def apply_nutrition_day_macros(
return "created"
def _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantity_g: float) -> None:
if not food_id or not quantity_g or quantity_g <= 0:
return
cur.execute(
"""
SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type
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
""",
(food_id,),
)
factor = float(quantity_g) / 100.0
for row in cur.fetchall():
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",
@ -207,28 +229,24 @@ def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
)
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
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():
cur.execute(
"""
SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type
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
""",
(it["food_id"],),
)
factor = float(it["quantity_g"]) / 100.0
for row in cur.fetchall():
acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
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():
cur.execute(
"""
@ -302,9 +320,9 @@ def replace_csv_items_for_dates(
id, profile_id, date, logged_at, source_name_raw, source_name_normalized,
source_system, quantity_raw, quantity_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 (
%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["mapping_id"] if mapping else None,
_item_value_origin(mapping),
None,
),
)
items_written += 1
from data_layer.food_recipes import link_recipes_to_items
link_recipes_to_items(cur, profile_id)
days_written += 1
sums = compute_day_macro_sums(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
FROM nutrition_items
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()]

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.execute(
"""
SELECT source_name_raw, source_name_normalized,
COUNT(*) AS count, MIN(date) AS first_date, MAX(date) AS last_date
FROM nutrition_items
WHERE profile_id=%s AND food_id IS NULL
GROUP BY source_name_raw, source_name_normalized
ORDER BY count DESC, source_name_normalized
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
MIN(r.id::text) AS matching_recipe_id
FROM nutrition_items i
LEFT JOIN food_recipes r
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
WHERE i.profile_id=%s AND i.food_id IS NULL AND i.recipe_id IS NULL
GROUP BY i.source_name_raw, i.source_name_normalized
ORDER BY count DESC, i.source_name_normalized
""",
(pid,),
)
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")

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"
BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260912" # 062 BLS catalog + nutrition items/marks
DB_SCHEMA_VERSION = "20260912" # 063 FDDB recipes
MODULE_VERSIONS = {
"auth": "1.2.0",
@ -20,7 +20,7 @@ MODULE_VERSIONS = {
"circumference": "1.0.1",
"caliper": "1.0.1",
"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",
"photos": "1.0.0",
"insights": "1.3.0",
@ -45,6 +45,8 @@ CHANGELOG = [
"Lernendes FDDB-Mapping ohne KI, änder- und löschbar",
"Optionale nutrition_items, Import-Policy, Fasten-/Lücken-Marken",
"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
- Fasten-/Lücken-Marken unabhängig vom Import
- 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 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 }) {
const [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([])
const [error, setError] = useState(null)
const [query, setQuery] = useState({})
const [hits, setHits] = useState({})
const [notice, setNotice] = 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 () => {
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)
setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) {
setError(e.message)
}
@ -21,26 +103,27 @@ export default function NutritionFoodMap({ onChanged }) {
useEffect(() => { load() }, [])
const search = async (key, q) => {
setQuery((s) => ({ ...s, [key]: q }))
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)
const assign = async (sourceName, foodId) => {
setSaving(sourceName)
setError(null)
try {
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()
onChanged?.()
} 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 (
<div className="card section-gap">
<div className="card-title">Lebensmittel zuordnen</div>
<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.
Du kannst sie jederzeit ändern oder löschen. Keine automatische KI-Zuordnung.
Tippe auf Im Katalog suchen du suchst nach dem Namen, nicht nach einem Code.
Eigene FDDB-Listen zuerst importieren, dann wird die Tagebuchzeile in Zutaten aufgelöst.
</p>
{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>
{unmapped.length === 0 && <p className="muted">Keine ungemappten Bezeichner.</p>}
<input
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) => {
const key = u.source_name_normalized
const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
return (
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ fontWeight: 600 }}>{u.source_name_raw}</div>
<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>
<input
className="form-input"
style={{ marginTop: 6 }}
placeholder="BLS-Code oder Name suchen…"
value={query[key] || ''}
onChange={(e) => search(key, e.target.value)}
/>
{(hits[key] || []).map((h) => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button
key={h.id}
type="button"
className="btn btn-secondary"
style={{ marginTop: 6, marginRight: 6 }}
disabled={saving === key}
onClick={() => assign(u.source_name_raw, h.id, key)}
className="btn btn-primary"
disabled={saving === u.source_name_raw}
onClick={() => setSearchFor(u)}
>
{h.bls_code ? `${h.bls_code} · ` : ''}{h.name_de}
{h.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
Im Katalog suchen
</button>
))}
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button
type="button"
className="btn btn-secondary"
disabled={saving === u.source_name_raw}
onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}
>
Als eigenes Rezept auflösen
</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>
)
})}
@ -110,13 +245,30 @@ export default function NutritionFoodMap({ onChanged }) {
<div>
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
<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)' : ''}
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
</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>
)
}
@ -132,7 +284,7 @@ export function DayMarkButtons({ date, markType, onChanged }) {
}
}
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')}>
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
</button>

View File

@ -934,7 +934,7 @@ export default function NutritionPage() {
<h1 className="page-title">Ernährung</h1>
{unmappedCount > 0 && (
<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')}>
Jetzt zuordnen
</button>

View File

@ -241,6 +241,13 @@ export const api = {
},
listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'),
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'),
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
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 page.getByRole('button', { name: /Zuordnen/i }).click();
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 }) => {