feat: Zuordnen ohne Freeze und zuerst die letzten Wochen
All checks were successful
Deploy Development / deploy (push) Successful in 1m6s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 29s

Katalogsuche und Bestätigen blockieren die UI nicht mehr; offene Namen starten bei den aktuellen Tagebucheinträgen, alter Ballast bleibt unter Alle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-12 16:14:24 +02:00
parent c1873a47b1
commit 132a364a3a
17 changed files with 745 additions and 163 deletions

View File

@ -8,7 +8,7 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte
## Zuordnung (UX)
Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen. Fehlt ein Treffer, kann ein **eigener Katalogeintrag** (Name + Makros/100 g) angelegt und sofort zugeordnet werden. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping.
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.
## FDDB-Listen / eigene Rezepte

View File

@ -34,7 +34,8 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
- `/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, eigener Eintrag, Gramm/Einheit), Listen-Import auf dem Tab Zuordnen
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen (Rebuild darf das Mapping nicht zurückrollen)
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce)
- `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)
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).

View File

@ -122,6 +122,8 @@ frontend/src/
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/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-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
- **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

@ -130,6 +130,8 @@ def upsert_foods(cur, foods: list[dict[str, Any]], bls_version: str = "4.0") ->
page_size=VALUE_PAGE,
)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index()
return {
"inserted": inserted,
"updated": updated,

View File

@ -242,6 +242,8 @@ def import_food_knowledge(cur, profile_id: str, data: dict[str, Any]) -> dict[st
dates.update(recipe_stats.pop("dates_linked", []) or [])
for day in dates:
rebuild_daily_nutrients(cur, profile_id, day)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index(profile_id)
return {
"ok": True,
"manual_foods": foods_upserted,

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import re
import unicodedata
from datetime import date, timedelta
from typing import Any
LEADING_QTY_RE = re.compile(
@ -64,6 +65,44 @@ def merge_unmapped_rows(rows: list[dict]) -> list[dict]:
return list(merged.values())
def as_iso_date(value: Any) -> str | None:
if value is None or value == "":
return None
if hasattr(value, "isoformat"):
return str(value.isoformat())[:10]
text = str(value).strip()
return text[:10] if len(text) >= 10 else None
def filter_unmapped_since(rows: list[dict], since_days: int, today: date | None = None) -> list[dict]:
"""Keep names last eaten in the window. Rows without last_date drop out (old recipe leftovers)."""
days = int(since_days or 0)
if days <= 0:
return list(rows)
cutoff = ((today or date.today()) - timedelta(days=days)).isoformat()
out = []
for row in rows:
last = as_iso_date(row.get("last_date"))
if last and last >= cutoff:
out.append(row)
return out
def sort_unmapped_rows(rows: list[dict], since_days: int = 0) -> list[dict]:
rows = list(rows)
if int(since_days or 0) > 0:
rows.sort(
key=lambda x: (
as_iso_date(x.get("last_date")) or "",
int(x.get("count") or 0),
),
reverse=True,
)
else:
rows.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
return rows
UNIT_ALIASES = {
"g": "g", "gr": "g", "gramm": "g",
"kg": "kg",

View File

@ -0,0 +1,214 @@
"""In-memory catalog suggestions: Haferflocken → Hafer Flocken, without opening a dialog."""
from __future__ import annotations
import re
import time
from typing import Any
from data_layer.food_mapping import normalize_food_name
_INDEX_TTL_SEC = 300
_index_cache: dict[str, tuple[float, dict[str, Any]]] = {}
MAX_CANDIDATES = 60
MAX_BUCKET = 40
SPLIT_RE = re.compile(r"[^a-z0-9äöüß]+")
COLLAPSE_RE = re.compile(r"[^a-z0-9äöüß]")
MIN_SCORE = 45
def collapse_key(raw: str | None) -> str:
return COLLAPSE_RE.sub("", normalize_food_name(raw))
def name_tokens(raw: str | None) -> list[str]:
return [t for t in SPLIT_RE.split(normalize_food_name(raw)) if len(t) >= 2]
def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int:
qn = normalize_food_name((query or "").split(",")[0])
nn = normalize_food_name(name_de)
if not qn or not nn:
return 0
qc, nc = collapse_key(qn), collapse_key(nn)
if qn == nn:
return 100
if qc and qc == nc:
return 95
if qc and nc.startswith(qc) and len(qc) >= 4:
return 82
if nc and qc.startswith(nc) and len(nc) >= 4:
return 78
qt, nt = set(name_tokens(qn)), set(name_tokens(nn))
if qt and qt <= nt:
return 72
if nt and nt <= qt:
return 68
if qt and nt:
overlap = len(qt & nt) / len(qt | nt)
if overlap >= 0.5:
return 50 + int(overlap * 20)
if qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
return 55 if abs(len(qc) - len(nc)) <= 8 else 46
en = normalize_food_name(name_en or "")
if en and (qn == en or collapse_key(en) == qc):
return 88
return 0
def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
return {
"id": str(food["id"]),
"bls_code": food.get("bls_code"),
"name_de": food.get("name_de"),
"name_en": food.get("name_en"),
"catalog_kind": food.get("catalog_kind"),
"food_group": food.get("food_group"),
"score": score,
}
def invalidate_suggest_index(profile_id: str | None = None) -> None:
if profile_id is None:
_index_cache.clear()
return
_index_cache.pop(str(profile_id), None)
_index_cache.pop("global", None)
def get_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
key = str(profile_id or "global")
hit = _index_cache.get(key)
if hit and (time.monotonic() - hit[0]) < _INDEX_TTL_SEC:
return hit[1]
index = load_suggest_index(cur, profile_id)
_index_cache[key] = (time.monotonic(), index)
return index
def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
cur.execute(
"""
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
FROM food_catalog
WHERE is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
""",
(profile_id,),
)
foods = [dict(r) for r in cur.fetchall()]
by_collapse: dict[str, list] = {}
by_token: dict[str, list] = {}
by_prefix: dict[str, list] = {}
by_suffix: dict[str, list] = {}
for food in foods:
food["_c"] = collapse_key(food.get("name_de"))
food["_t"] = name_tokens(food.get("name_de"))
if food["_c"]:
by_collapse.setdefault(food["_c"], []).append(food)
by_prefix.setdefault(food["_c"][:4], []).append(food)
if len(food["_c"]) >= 4:
by_suffix.setdefault(food["_c"][-4:], []).append(food)
for tok in food["_t"]:
by_token.setdefault(tok, []).append(food)
return {
"foods": foods,
"by_collapse": by_collapse,
"by_token": by_token,
"by_prefix": by_prefix,
"by_suffix": by_suffix,
}
def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]:
qc = collapse_key(query)
seen: set[str] = set()
out: list[dict[str, Any]] = []
def add(food: dict[str, Any]) -> None:
fid = str(food["id"])
if fid in seen:
return
seen.add(fid)
out.append(food)
if qc:
for food in index["by_collapse"].get(qc, []):
add(food)
if len(qc) >= 4:
prefix_hits = index["by_prefix"].get(qc[:4], [])
if len(prefix_hits) > MAX_BUCKET:
prefix_hits = [
food for food in prefix_hits
if (food.get("_c") or "").startswith(qc) or qc.startswith(food.get("_c") or "")
]
for food in prefix_hits[:MAX_CANDIDATES]:
add(food)
suffix_hits = index["by_suffix"].get(qc[-4:], [])
if len(suffix_hits) <= MAX_BUCKET:
for food in suffix_hits:
add(food)
for tok in name_tokens(query):
token_hits = index["by_token"].get(tok, [])
if len(token_hits) > MAX_BUCKET:
token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET]
for food in token_hits:
add(food)
if len(out) >= MAX_CANDIDATES:
break
return out[:MAX_CANDIDATES]
def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
q = (query or "").strip()
scored: list[tuple[int, int, dict]] = []
for food in _candidate_foods(index, q):
score = score_name_match(q, food.get("name_de") or "", food.get("name_en"))
if score < MIN_SCORE:
continue
scored.append((score, len(food.get("name_de") or ""), food))
scored.sort(key=lambda x: (-x[0], x[1], x[2].get("name_de") or ""))
top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]]
ambiguous = False
if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60:
ambiguous = True
elif len(top) >= 2 and top[0]["score"] < 90:
ambiguous = True
return {
"suggestions": top[:limit],
"suggestion_count": len(top),
"ambiguous": ambiguous,
}
def attach_suggestions(index: dict[str, Any], rows: list[dict[str, Any]], limit: int = 3) -> list[dict[str, Any]]:
for row in rows:
q = row.get("source_name_raw") or row.get("source_name_normalized") or ""
packed = suggest_for_name(index, q, limit=limit)
row["suggestions"] = packed["suggestions"]
row["suggestion_count"] = packed["suggestion_count"]
row["ambiguous"] = packed["ambiguous"]
return rows
def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
q = (query or "").strip()
if len(q) < 2:
return []
index = get_suggest_index(cur, profile_id)
packed = suggest_for_name(index, q, limit=limit)
if packed["suggestions"]:
return packed["suggestions"]
from data_layer.food_mapping import suggest_catalog_foods
return suggest_catalog_foods(cur, q, profile_id, limit=limit)
def suggest_batch(cur, profile_id: str | None, names: list[str], limit: int = 3) -> dict[str, dict[str, Any]]:
index = get_suggest_index(cur, profile_id)
out = {}
for raw in names[:100]:
key = (raw or "").strip()
if not key or key in out:
continue
out[key] = suggest_for_name(index, key, limit=limit)
return out

View File

@ -216,6 +216,8 @@ def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(req
)
food = r2d(cur.fetchone())
_write_manual_macros(cur, food["id"], body.macros_per_100g)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index()
return food

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import threading
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
@ -13,9 +14,13 @@ from data_layer.food_mapping import (
apply_quantities_to_items,
clear_mapping_from_items,
normalize_food_name,
suggest_catalog_foods,
upsert_food_mapping,
)
from data_layer.food_suggest import (
invalidate_suggest_index,
suggest_batch,
suggest_catalog_foods_ranked,
)
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
from db import get_cursor, get_db, r2d
from routers.profiles import get_pid
@ -39,6 +44,11 @@ class MappingUpsert(BaseModel):
source_unit: Optional[str] = None
class SuggestBatchBody(BaseModel):
names: list[str]
limit: int = 3
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
return x_profile_id or session["profile_id"]
@ -48,6 +58,25 @@ def _rebuild_days(cur, profile_id: str, dates: list[str]) -> None:
rebuild_daily_nutrients(cur, profile_id, d)
def _rebuild_days_bg(profile_id: str, dates: list[str], context: str) -> None:
if not dates:
return
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), profile_id, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach %s fehlgeschlagen", context)
def _schedule_rebuild(profile_id: str, dates: list[str], context: str) -> None:
threading.Thread(
target=_rebuild_days_bg,
args=(profile_id, list(dates), context),
daemon=True,
name="nutrition-rebuild",
).start()
@router.get("/foods")
def search_foods(
q: str = "",
@ -57,7 +86,19 @@ def search_foods(
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
return suggest_catalog_foods(cur, q, pid, limit=min(max(limit, 1), 50))
return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50))
@router.post("/foods/suggest-batch")
def suggest_foods_batch(
body: SuggestBatchBody,
session: dict = Depends(require_auth),
):
names = [n for n in (body.names or []) if isinstance(n, str)][:80]
limit = min(max(body.limit or 3, 1), 5)
with get_db() as conn:
cur = get_cursor(conn)
return suggest_batch(cur, session["profile_id"], names, limit=limit)
@router.post("/foods/manual")
@ -86,6 +127,7 @@ def create_user_food(
)
food = r2d(cur.fetchone())
_write_manual_macros(cur, food["id"], body.macros_per_100g)
invalidate_suggest_index(pid)
return food
@ -123,13 +165,14 @@ def upsert_my_mapping(
cur = get_cursor(conn)
cur.execute(
"""
SELECT id FROM food_catalog
SELECT id, name_de, bls_code, catalog_kind FROM food_catalog
WHERE id = %s AND is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
""",
(body.food_id, pid),
)
if not cur.fetchone():
food = cur.fetchone()
if not food:
raise HTTPException(404, "Lebensmittel nicht gefunden")
mid = upsert_food_mapping(
cur,
@ -145,12 +188,16 @@ def upsert_my_mapping(
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
dates = dates_for_normalized_name(cur, pid, norm)
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), pid, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach Mapping %s fehlgeschlagen", norm)
return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm}
_schedule_rebuild(pid, dates, f"Mapping {norm}")
return {
"mapping_id": mid,
"items_updated": n,
"source_name_normalized": norm,
"food_id": body.food_id,
"food_name_de": food["name_de"],
"bls_code": food.get("bls_code"),
"catalog_kind": food.get("catalog_kind"),
}
@router.delete("/mappings/{mapping_id}")
@ -176,11 +223,7 @@ def delete_my_mapping(
dates = dates_for_normalized_name(cur, pid, norm)
clear_mapping_from_items(cur, pid, norm)
cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid))
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), pid, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach Mapping-Löschen fehlgeschlagen")
_schedule_rebuild(pid, dates, "Mapping-Löschen")
return {"ok": True}

View File

@ -347,10 +347,18 @@ def list_nutrition_items(
@router.get("/unmapped")
def list_unmapped_foods(
since_days: int = 0,
count_only: bool = False,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name
from data_layer.food_mapping import (
as_iso_date,
filter_unmapped_since,
merge_unmapped_rows,
normalize_food_name,
sort_unmapped_rows,
)
pid = x_profile_id or session["profile_id"]
with get_db() as conn:
@ -383,18 +391,27 @@ def list_unmapped_foods(
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date,
COUNT(*) AS count, u.first_used AS first_date, u.last_used AS last_date,
MIN(i.quantity_raw) AS sample_quantity_raw
FROM food_recipe_ingredients i
JOIN food_recipes r ON r.id = i.recipe_id
LEFT JOIN food_name_mappings m
ON m.profile_id = r.profile_id
AND m.source_name_normalized = i.source_name_normalized
LEFT JOIN (
SELECT r2.id AS recipe_id, MIN(ni.date) AS first_used, MAX(ni.date) AS last_used
FROM food_recipes r2
JOIN nutrition_items ni
ON ni.profile_id = r2.profile_id
AND (ni.recipe_id = r2.id OR ni.source_name_normalized = r2.name_normalized)
WHERE r2.profile_id = %s
GROUP BY r2.id
) u ON u.recipe_id = r.id
WHERE r.profile_id = %s AND m.id IS NULL
GROUP BY i.source_name_raw, i.source_name_normalized
GROUP BY i.source_name_raw, i.source_name_normalized, u.first_used, u.last_used
ORDER BY count DESC, i.source_name_normalized
""",
(pid,),
(pid, pid),
)
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
merged = merge_unmapped_rows(diary + ings)
@ -403,9 +420,14 @@ def list_unmapped_foods(
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
if key in mapped:
continue
row["last_date"] = as_iso_date(row.get("last_date"))
row["first_date"] = as_iso_date(row.get("first_date"))
out.append(row)
out.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
return out
days = max(0, min(int(since_days or 0), 3650))
recent = sort_unmapped_rows(filter_unmapped_since(out, days), days)
if count_only:
return {"count": len(recent), "total": len(out), "since_days": days}
return recent if days else sort_unmapped_rows(out, 0)
@router.get("/recipes")

View File

@ -1,5 +1,13 @@
from datetime import date
from csv_parser.executor import guess_nutrition_item_fields
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name, parse_quantity, parse_quantity_g
from data_layer.food_mapping import (
filter_unmapped_since,
merge_unmapped_rows,
normalize_food_name,
parse_quantity,
parse_quantity_g,
)
from data_layer.nutrition_items import macros_differ
@ -42,6 +50,18 @@ def test_guess_fddb_bezeichnung_without_template_mapping():
assert qty == "50 g"
def test_recent_window_drops_old_and_dateless_foods():
today = date(2026, 9, 12)
rows = [
{"source_name_raw": "Haferflocken", "last_date": "2026-09-10", "count": 2},
{"source_name_raw": "Weißbrot", "last_date": "2026-01-02", "count": 40},
{"source_name_raw": "Altes Rezept-Salz", "last_date": None, "count": 1},
]
recent = filter_unmapped_since(rows, 28, today=today)
assert [r["source_name_raw"] for r in recent] == ["Haferflocken"]
assert len(filter_unmapped_since(rows, 0, today=today)) == 3
def test_macros_differ_rounds():
assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0})

View File

@ -0,0 +1,65 @@
from data_layer.food_suggest import collapse_key, score_name_match, suggest_batch, suggest_for_name
def test_collapse_treats_space_as_same():
assert collapse_key("Haferflocken") == collapse_key("Hafer Flocken")
def test_haferflocken_ranks_simple_name_first():
index = {
"foods": [],
"by_collapse": {},
"by_token": {},
"by_prefix": {},
"by_suffix": {},
}
simple = {"id": "1", "name_de": "Hafer Flocken", "name_en": "oat flakes", "bls_code": "C131111", "catalog_kind": "official_bls"}
dish = {"id": "2", "name_de": "Milch-Getreide-Brei, mit Haferflocken und Apfelsaft (geeignet für Beikost)", "name_en": "", "bls_code": "X", "catalog_kind": "official_bls"}
for food in (simple, dish):
food["_c"] = collapse_key(food["name_de"])
index["by_collapse"].setdefault(food["_c"], []).append(food)
index["by_prefix"].setdefault(food["_c"][:4], []).append(food)
index["by_suffix"].setdefault(food["_c"][-4:], []).append(food)
packed = suggest_for_name(index, "Haferflocken", limit=3)
assert packed["suggestions"][0]["name_de"] == "Hafer Flocken"
assert packed["suggestions"][0]["score"] >= score_name_match("Haferflocken", dish["name_de"])
def test_close_scores_are_ambiguous():
a = {"id": "1", "name_de": "Olivenöl nativ", "name_en": "", "bls_code": "A", "catalog_kind": "official_bls"}
b = {"id": "2", "name_de": "Olivenöl raffiniert", "name_en": "", "bls_code": "B", "catalog_kind": "official_bls"}
index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}}
for food in (a, b):
food["_c"] = collapse_key(food["name_de"])
index["by_prefix"].setdefault(food["_c"][:4], []).append(food)
for tok in food["name_de"].lower().replace(",", "").split():
if len(tok) >= 2:
index["by_token"].setdefault(tok, []).append(food)
packed = suggest_for_name(index, "Olivenöl", limit=3)
assert packed["ambiguous"] is True
assert packed["suggestion_count"] >= 2
def test_large_prefix_bucket_keeps_exact_collapse():
index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}}
target = {"id": "hit", "name_de": "Hafer Flocken", "name_en": "", "bls_code": "C", "catalog_kind": "official_bls"}
target["_c"] = collapse_key(target["name_de"])
index["by_collapse"][target["_c"]] = [target]
index["by_prefix"][target["_c"][:4]] = [target] + [
{"id": str(i), "name_de": f"Hafer Gericht {i}", "name_en": "", "_c": f"hafergericht{i}"}
for i in range(80)
]
packed = suggest_for_name(index, "Haferflocken", limit=3)
assert packed["suggestions"][0]["name_de"] == "Hafer Flocken"
def test_suggest_batch_dedupes_names():
class _Cur:
def execute(self, *args, **kwargs):
return None
def fetchall(self):
return []
out = suggest_batch(_Cur(), None, ["Haferflocken", "Haferflocken", ""], limit=2)
assert "Haferflocken" in out
assert out["Haferflocken"]["suggestions"] == []

View File

@ -20,8 +20,8 @@ MODULE_VERSIONS = {
"circumference": "1.0.1",
"caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.2.2", # mapping save + manual food + units
"bls": "1.0.1",
"nutrition": "1.2.5", # unmapped since_days: last weeks first
"bls": "1.0.2",
"photos": "1.0.0",
"insights": "1.3.0",
"prompts": "1.1.0",
@ -49,6 +49,9 @@ CHANGELOG = [
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen",
"Zuordnungen und Listen als JSON exportieren/importieren (Dev → Prod)",
"Zuordnen: Mapping unabhängig vom Nährwert-Rebuild; eigener Katalogeintrag; Mengeneinheiten",
"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: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen",
],
},
{

View File

@ -41,6 +41,8 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
)
const inputRef = useRef(null)
const timer = useRef(null)
const abortRef = useRef(null)
const seqRef = useRef(0)
const extras = () => {
const g = parseFloat(String(gramsPerUnit).replace(',', '.'))
@ -50,19 +52,28 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
const runSearch = async (term) => {
const query = (term || '').trim()
abortRef.current?.abort()
if (query.length < 2) {
setHits([])
setLoading(false)
return
}
const seq = ++seqRef.current
const ac = new AbortController()
abortRef.current = ac
setLoading(true)
setError(null)
try {
setHits(await api.searchBlsFoods(query, 30))
const next = await api.searchBlsFoods(query, 20, ac.signal)
if (seq !== seqRef.current) return
setHits(next)
} catch (e) {
if (e.name === 'AbortError') return
if (seq !== seqRef.current) return
setError(e.message)
setHits([])
} finally {
setLoading(false)
if (seq === seqRef.current) setLoading(false)
}
}
@ -75,13 +86,20 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
return () => {
window.removeEventListener('keydown', onKey)
clearTimeout(timer.current)
abortRef.current?.abort()
}
}, [])
const onChange = (value) => {
setQ(value)
clearTimeout(timer.current)
timer.current = setTimeout(() => runSearch(value), 250)
if (value.trim().length < 2) {
abortRef.current?.abort()
setHits([])
setLoading(false)
return
}
timer.current = setTimeout(() => runSearch(value), 400)
}
const createManual = async () => {

View File

@ -1,7 +1,21 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
const PERIODS = [
{ days: 14, label: '14 Tage' },
{ days: 28, label: '4 Wochen' },
{ days: 90, label: '90 Tage' },
{ days: 0, label: 'Alle' },
]
function formatDay(iso) {
const s = String(iso || '').slice(0, 10)
if (s.length < 10) return ''
const [y, m, d] = s.split('-')
return `${d}.${m}.${String(y).slice(2)}`
}
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()
@ -40,31 +54,15 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
<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)}
/>
<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.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept.</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)}
>
<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 style={{ fontSize: 12, color: 'var(--text3)' }}>{(r.ingredients || []).length} Zutaten</span>
</span>
</button>
))}
@ -74,7 +72,46 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
)
}
export default function NutritionFoodMap({ onChanged }) {
function InlineCreate({ defaultName, disabled, onCreate }) {
const [name, setName] = useState(defaultName || '')
const [kcal, setKcal] = useState('0')
const [protein, setProtein] = useState('0')
const [fat, setFat] = useState('0')
const [carbs, setCarbs] = useState('0')
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 (Werte pro 100 g, Salz z. B. alles 0)</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: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
{[['kcal', kcal, setKcal], ['Protein', protein, setProtein], ['Fett', fat, setFat], ['KH', carbs, setCarbs]].map(([label, val, set]) => (
<label key={label} style={{ fontSize: 11, color: 'var(--text3)' }}>
{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>
<button
type="button"
className="btn btn-primary btn-full"
style={{ marginTop: 8 }}
disabled={disabled || !name.trim()}
onClick={() => onCreate({
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
</button>
</div>
)
}
export default function NutritionFoodMap({ onChanged, onMapped }) {
const [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([])
@ -83,22 +120,33 @@ export default function NutritionFoodMap({ onChanged }) {
const [saving, setSaving] = useState(null)
const [searchFor, setSearchFor] = useState(null)
const [recipeFor, setRecipeFor] = useState(null)
const [createFor, setCreateFor] = useState(null)
const [filter, setFilter] = useState('all')
const [sinceDays, setSinceDays] = useState(28)
const [totalOpen, setTotalOpen] = useState(0)
const [q, setQ] = useState('')
const [visible, setVisible] = useState(80)
const [showLearned, setShowLearned] = useState(false)
const [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false)
const listRef = useRef(null)
const bundleRef = useRef(null)
const loadGen = useRef(0)
const askedSuggest = useRef(new Set())
const load = async () => {
const gen = ++loadGen.current
try {
const [u, m, r] = await Promise.all([
api.listUnmappedFoods(),
const [u, m, r, counts] = await Promise.all([
api.listUnmappedFoods(sinceDays),
api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []),
api.listUnmappedFoodCount(sinceDays).catch(() => null),
])
if (gen !== loadGen.current) return
setUnmapped(u)
askedSuggest.current = new Set()
setUnmapped(Array.isArray(u) ? u : [])
setTotalOpen(Number(counts?.total) || (Array.isArray(u) ? u.length : 0))
setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) {
@ -107,22 +155,75 @@ export default function NutritionFoodMap({ onChanged }) {
}
}
useEffect(() => { load() }, [])
useEffect(() => { load() }, [sinceDays])
const assign = async (sourceName, foodId, extras = {}) => {
const filtered = useMemo(() => {
const term = q.trim().toLowerCase()
return unmapped.filter((u) => {
if (filter === 'suggested' && !(u.suggestions || []).length) return false
if (filter === 'none' && (u.suggestions || []).length) return false
if (!term) return true
const hay = `${u.source_name_raw || ''} ${u.source_name_normalized || ''}`.toLowerCase()
return hay.includes(term)
})
}, [unmapped, filter, q])
const shown = filtered.slice(0, visible)
useEffect(() => {
const need = []
const seen = new Set()
const take = (u) => {
const key = u.source_name_normalized
if (!key || askedSuggest.current.has(key) || seen.has(key)) return
seen.add(key)
need.push(u)
}
shown.forEach(take)
if (need.length < 80) {
for (const u of unmapped) {
take(u)
if (need.length >= 80) break
}
}
if (!need.length) return
need.forEach((u) => askedSuggest.current.add(u.source_name_normalized))
const names = need.map((u) => u.source_name_raw).filter(Boolean)
if (!names.length) return
api.suggestFoodsBatch(names, 3).then((packed) => {
if (!packed || typeof packed !== 'object') return
setUnmapped((list) => list.map((u) => {
const hit = packed[u.source_name_raw]
return hit ? { ...u, ...hit } : u
}))
}).catch(() => {
need.forEach((u) => askedSuggest.current.delete(u.source_name_normalized))
})
}, [visible, filter, q, unmapped.length])
const assign = async (row, foodId, extras = {}, foodMeta = {}) => {
const sourceName = row.source_name_raw
setSaving(sourceName)
setError(null)
try {
await api.upsertMyFoodMapping({
const res = await api.upsertMyFoodMapping({
source_name: sourceName,
food_id: foodId,
grams_per_unit: extras.grams_per_unit || null,
source_unit: extras.source_unit || null,
})
setSearchFor(null)
setNotice(`Zuordnung gespeichert: ${sourceName}`)
await load()
onChanged?.()
setCreateFor(null)
setUnmapped((list) => list.filter((x) => x.source_name_normalized !== row.source_name_normalized))
setLearned((list) => [{
id: res.mapping_id,
source_name_raw: sourceName,
food_name_de: res.food_name_de || foodMeta.name_de,
bls_code: res.bls_code || foodMeta.bls_code,
catalog_kind: res.catalog_kind || foodMeta.catalog_kind,
}, ...list])
setNotice(`Gespeichert: ${suggestQuery(sourceName) || sourceName}`)
onMapped?.()
} catch (e) {
setError(e.message)
} finally {
@ -130,14 +231,26 @@ export default function NutritionFoodMap({ onChanged }) {
}
}
const createAndAssign = async (row, body) => {
setSaving(row.source_name_raw)
setError(null)
try {
const food = await api.createUserFood(body)
await assign(row, food.id, {}, { name_de: food.name_de, catalog_kind: food.catalog_kind })
} catch (e) {
setError(e.message)
setSaving(null)
}
}
const applyRecipe = async (sourceName, recipeId) => {
setSaving(sourceName)
setError(null)
try {
await api.applyNutritionRecipe(recipeId, sourceName)
setRecipeFor(null)
await load()
onChanged?.()
setUnmapped((list) => list.filter((x) => x.source_name_raw !== sourceName))
onMapped?.()
} catch (e) {
setError(e.message)
} finally {
@ -149,6 +262,7 @@ export default function NutritionFoodMap({ onChanged }) {
if (!confirm('Zuordnung wirklich löschen?')) return
try {
await api.deleteMyFoodMapping(id)
setLearned((list) => list.filter((x) => x.id !== id))
await load()
onChanged?.()
} catch (e) {
@ -159,7 +273,6 @@ export default function NutritionFoodMap({ onChanged }) {
const exportBundle = async () => {
setBusy(true)
setError(null)
setNotice(null)
try {
await api.exportFoodKnowledge()
setNotice('Zuordnungen und Listen als JSON heruntergeladen.')
@ -174,14 +287,12 @@ export default function NutritionFoodMap({ onChanged }) {
if (!file) return
setBusy(true)
setError(null)
setNotice(null)
try {
const res = await api.importFoodKnowledge(file)
await load()
onChanged?.()
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
const lists = (res.inserted || 0) + (res.updated || 0)
setNotice(`${res.mappings || 0} Zuordnungen und ${lists} Listen übernommen${skip}.`)
setNotice(`${res.mappings || 0} Zuordnungen und ${(res.inserted || 0) + (res.updated || 0)} Listen übernommen${skip}.`)
} catch (e) {
setError(e.message)
} finally {
@ -193,12 +304,11 @@ export default function NutritionFoodMap({ onChanged }) {
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.`)
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.`)
} catch (e) {
setError(e.message)
} finally {
@ -206,114 +316,138 @@ export default function NutritionFoodMap({ onChanged }) {
}
}
const withSuggest = unmapped.filter((u) => (u.suggestions || []).length).length
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 }}>
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.
Für den Umzug nach Prod: Zuordnungen und Listen als JSON exportieren und dort wieder importieren.
Zuerst die letzten Wochen zuordnen. Ältere Namen (z. B. Getreide, das du nicht mehr isst) bleiben unter Alle liegen und müssen nicht gemappt werden.
</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>}
<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()}
>
<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>
)}
<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)
}}
/>
{recipes.length > 0 && <p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>}
<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 }}>
<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={() => bundleRef.current?.click()}>
Zuordnungen & Listen importieren
</button>
<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={() => bundleRef.current?.click()}>Zuordnungen & Listen importieren</button>
</div>
<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.kind || 'diary'}-${u.source_name_normalized}`
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>
Offen ({filtered.length}{sinceDays && totalOpen > unmapped.length ? ` von ${totalOpen}` : filtered.length !== unmapped.length ? ` / ${unmapped.length}` : ''})
</h3>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>{withSuggest} mit Vorschlag</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{PERIODS.map((p) => (
<button
key={p.days}
type="button"
className={sinceDays === p.days ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ fontSize: 12 }}
onClick={() => { setSinceDays(p.days); setVisible(80); setQ('') }}
>
{p.label}
</button>
))}
</div>
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} placeholder="Offene Liste filtern…" value={q} onChange={(e) => { setQ(e.target.value); setVisible(80) }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{[['all', 'Alle Treffer'], ['suggested', 'Mit Vorschlag'], ['none', 'Ohne Vorschlag']].map(([id, label]) => (
<button key={id} type="button" className={filter === id ? 'btn btn-primary' : 'btn btn-secondary'} style={{ fontSize: 12 }} onClick={() => { setFilter(id); setVisible(80) }}>{label}</button>
))}
</div>
{shown.length === 0 && (
<p className="muted">
{sinceDays && unmapped.length === 0 && totalOpen > 0
? `In diesem Zeitraum ist nichts Offen. ${totalOpen} ältere Namen liegen unter „Alle“ — die brauchst du nicht, wenn du sie nicht mehr isst.`
: 'Keine offenen Bezeichner in diesem Filter.'}
</p>
)}
{shown.map((u) => {
const key = u.source_name_normalized
const suggestions = u.suggestions || []
const best = suggestions[0]
const busyRow = saving === u.source_name_raw
return (
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen-Varianten` : ''}
{u.first_date ? ` · ${u.first_date} ${u.last_date}` : ''}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
<div>
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''}
{u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
</div>
</div>
{(u.ambiguous || suggestions.length > 1) && (
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 999, background: 'var(--surface2)', color: 'var(--danger)', whiteSpace: 'nowrap' }}>
mehrere möglich
</span>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button
type="button"
className="btn btn-primary"
disabled={saving === u.source_name_raw}
onClick={() => setSearchFor(u)}
>
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
{best && (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 13 }}>
<strong>{best.name_de}</strong>
<span style={{ color: 'var(--text3)' }}>{best.bls_code ? ` · ${best.bls_code}` : ' · manuell'}</span>
</span>
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => assign(u, best.id, {}, best)}>
Bestätigen
</button>
</div>
)}
{suggestions.slice(1).map((s) => (
<div key={s.id} style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''}</span>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => assign(u, s.id, {}, s)}>Übernehmen</button>
</div>
))}
{!best && <p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>Kein Katalogvorschlag selbst anlegen.</p>}
<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)}>
{createFor === key ? 'Anlegen schließen' : 'Neu anlegen'}
</button>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Rezept</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>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Rezept wählen</button>
)}
</div>
{createFor === key && (
<InlineCreate defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw} disabled={busyRow} onCreate={(body) => createAndAssign(u, body)} />
)}
</div>
)
})}
{visible < filtered.length && (
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 80)}>
Weitere {Math.min(80, filtered.length - visible)} zeigen
</button>
)}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Gelernt ({learned.length})</h3>
{learned.map((m) => (
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>
Gelernt ({learned.length}){' '}
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => setShowLearned((v) => !v)}>
{showLearned ? 'Einklappen' : 'Anzeigen'}
</button>
</h3>
{showLearned && learned.map((m) => (
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
{m.grams_per_unit ? ` · 1 ${m.source_unit || 'Einheit'} = ${m.grams_per_unit} g` : ''}
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
@ -326,16 +460,11 @@ export default function NutritionFoodMap({ onChanged }) {
initialQuery={suggestQuery(searchFor.source_name_raw)}
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
onClose={() => setSearchFor(null)}
onSelect={(food, extras) => assign(searchFor.source_name_raw, food.id, extras)}
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)}
/>
)}
{recipeFor && (
<RecipePickModal
recipes={recipes}
sourceName={recipeFor.source_name_raw}
onClose={() => setRecipeFor(null)}
onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)}
/>
<RecipePickModal recipes={recipes} sourceName={recipeFor.source_name_raw} onClose={() => setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} />
)}
</div>
)

View File

@ -899,6 +899,7 @@ export default function NutritionPage() {
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
const [nutritionUsage, setNutritionUsage] = useState(null)
const [unmappedCount, setUnmappedCount] = useState(0)
const [unmappedTotal, setUnmappedTotal] = useState(0)
const loadUsage = () => {
nutritionApi.getFeatureUsage().then(features => {
@ -915,26 +916,38 @@ export default function NutritionPage() {
nutritionApi.nutritionWeekly(16),
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
nutritionApi.getActiveProfile(),
nutritionApi.listUnmappedFoods().catch(() => []),
nutritionApi.listUnmappedFoodCount(28).catch(() => ({ count: 0, total: 0 })),
])
setCorr(Array.isArray(corr)?corr:[])
setWeekly(Array.isArray(wkly)?wkly:[])
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
setProf(prof)
setUnmappedCount(Array.isArray(unmapped) ? unmapped.length : 0)
setUnmappedCount(Number(unmapped?.count) || 0)
setUnmappedTotal(Number(unmapped?.total) || Number(unmapped?.count) || 0)
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
} catch(e) { console.error('load error:', e) }
finally { setLoad(false) }
}
const refreshUnmappedCount = async () => {
try {
const d = await nutritionApi.listUnmappedFoodCount(28)
setUnmappedCount(Number(d?.count) || 0)
setUnmappedTotal(Number(d?.total) || Number(d?.count) || 0)
} catch { /* Banner bleibt auf letztem Stand */ }
}
useEffect(() => { load(); loadUsage() }, [])
return (
<div className="capture-page">
<h1 className="page-title">Ernährung</h1>
{unmappedCount > 0 && (
{(unmappedCount > 0 || unmappedTotal > 0) && (
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
{unmappedCount} Lebensmittel noch ohne Katalog-Zuordnung.{' '}
{unmappedCount > 0
? `${unmappedCount} Lebensmittel der letzten 4 Wochen noch ohne Zuordnung${unmappedTotal > unmappedCount ? ` · ${unmappedTotal} insgesamt` : ''}.`
: `In den letzten 4 Wochen ist alles zugeordnet. ${unmappedTotal} ältere Namen ohne Zuordnung — nur nötig, wenn du sie wieder isst.`}
{' '}
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
Jetzt zuordnen
</button>
@ -968,11 +981,16 @@ export default function NutritionPage() {
</>
)}
{inputTab==='map' && <NutritionFoodMap onChanged={load} />}
{inputTab==='map' && (
<NutritionFoodMap
onMapped={refreshUnmappedCount}
onChanged={refreshUnmappedCount}
/>
)}
{loading && <div className="empty-state"><div className="spinner"/></div>}
{loading && inputTab !== 'map' && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && (
{!loading && !hasData && inputTab !== 'map' && (
<div className="empty-state">
<h3>Noch keine Ernährungsdaten</h3>
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
@ -980,7 +998,7 @@ export default function NutritionPage() {
)}
{/* Analysis Section */}
{!loading && hasData && (
{!loading && hasData && inputTab !== 'map' && (
<>
<OverviewCards data={corrData}/>

View File

@ -240,7 +240,8 @@ export const api = {
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
},
listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'),
listUnmappedFoods: () => req('/nutrition/unmapped'),
listUnmappedFoods: (sinceDays=0) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}`),
listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`),
listNutritionRecipes: () => req('/nutrition/recipes'),
importFddbLists: async (file) => {
const fd = new FormData(); fd.append('file', file)
@ -273,7 +274,8 @@ export const api = {
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
searchBlsFoods: (q, limit=20) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`),
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 })),
createUserFood: (d) => req('/bls/foods/manual', json(d)),
listMyFoodMappings: () => req('/bls/mappings'),
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),