diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 1b3c779..981d4ad 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -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 diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index 286e4ea..cdf3bb3 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index 3f28bc8..9614160 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/backend/bls/import_service.py b/backend/bls/import_service.py index 5ccf0b0..064917a 100644 --- a/backend/bls/import_service.py +++ b/backend/bls/import_service.py @@ -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, diff --git a/backend/data_layer/food_knowledge.py b/backend/data_layer/food_knowledge.py index 9bff82a..3f6ab66 100644 --- a/backend/data_layer/food_knowledge.py +++ b/backend/data_layer/food_knowledge.py @@ -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, diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index e729e4f..462b52c 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -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", diff --git a/backend/data_layer/food_suggest.py b/backend/data_layer/food_suggest.py new file mode 100644 index 0000000..9ded35b --- /dev/null +++ b/backend/data_layer/food_suggest.py @@ -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 diff --git a/backend/routers/admin_bls.py b/backend/routers/admin_bls.py index 3266e3e..310000f 100644 --- a/backend/routers/admin_bls.py +++ b/backend/routers/admin_bls.py @@ -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 diff --git a/backend/routers/bls.py b/backend/routers/bls.py index 4bc4b0c..c7e42f1 100644 --- a/backend/routers/bls.py +++ b/backend/routers/bls.py @@ -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} diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 53cd08a..438a697 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -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") diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index 8efae34..fbe68a3 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -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}) diff --git a/backend/tests/test_food_suggest.py b/backend/tests/test_food_suggest.py new file mode 100644 index 0000000..93295d4 --- /dev/null +++ b/backend/tests/test_food_suggest.py @@ -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"] == [] diff --git a/backend/version.py b/backend/version.py index 8894eba..28e8dd4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", ], }, { diff --git a/frontend/src/components/FoodSearchModal.jsx b/frontend/src/components/FoodSearchModal.jsx index fcb8d18..63d31cb 100644 --- a/frontend/src/components/FoodSearchModal.jsx +++ b/frontend/src/components/FoodSearchModal.jsx @@ -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 () => { diff --git a/frontend/src/components/NutritionFoodMap.jsx b/frontend/src/components/NutritionFoodMap.jsx index 7706508..4df2ee9 100644 --- a/frontend/src/components/NutritionFoodMap.jsx +++ b/frontend/src/components/NutritionFoodMap.jsx @@ -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 }) {
- setQ(e.target.value)} - /> + setQ(e.target.value)} />
- {filtered.length === 0 &&

Kein passendes Rezept. Zuerst Listen-CSV importieren.

} + {filtered.length === 0 &&

Kein passendes Rezept.

} {filtered.map((r) => ( - ))} @@ -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 ( +
+

Neuer Katalogeintrag (Werte pro 100 g, Salz z. B. alles 0)

+ setName(e.target.value)} placeholder="Name" /> +
+ {[['kcal', kcal, setKcal], ['Protein', protein, setProtein], ['Fett', fat, setFat], ['KH', carbs, setCarbs]].map(([label, val, set]) => ( + + ))} +
+ +
+ ) +} + +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 (
Lebensmittel zuordnen

- 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.

{error &&
{error}
} {notice &&
{notice}
} - { - const f = e.target.files?.[0] - e.target.value = '' - if (f) importLists(f) - }} - /> - - {recipes.length > 0 && ( -

{recipes.length} eigene Listen geladen

- )} - { - const f = e.target.files?.[0] - e.target.value = '' - if (f) importBundle(f) - }} - /> + {recipes.length > 0 &&

{recipes.length} eigene Listen geladen

} + { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
- - + +
-

Offen ({unmapped.length})

- {unmapped.length === 0 &&

Keine offenen Bezeichner.

} - {unmapped.map((u) => { - const key = `${u.kind || 'diary'}-${u.source_name_normalized}` +

+ Offen ({filtered.length}{sinceDays && totalOpen > unmapped.length ? ` von ${totalOpen}` : filtered.length !== unmapped.length ? ` / ${unmapped.length}` : ''}) +

+

{withSuggest} mit Vorschlag

+
+ {PERIODS.map((p) => ( + + ))} +
+ { setQ(e.target.value); setVisible(80) }} /> +
+ {[['all', 'Alle Treffer'], ['suggested', 'Mit Vorschlag'], ['none', 'Ohne Vorschlag']].map(([id, label]) => ( + + ))} +
+ + {shown.length === 0 && ( +

+ {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.'} +

+ )} + {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 (
-
{suggestQuery(u.source_name_raw) || u.source_name_raw}
-
- {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}` : ''} +
+
+
{suggestQuery(u.source_name_raw) || u.source_name_raw}
+
+ {u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`} + {u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''} + {u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''} +
+
+ {(u.ambiguous || suggestions.length > 1) && ( + + mehrere möglich + + )}
-
- - {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( - +
+ )} + {suggestions.slice(1).map((s) => ( +
+ oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''} + +
+ ))} + {!best &&

Kein Katalogvorschlag — selbst anlegen.

} + +
+ + + {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( + )} {u.kind !== 'recipe_ingredient' && recipes.length > 0 && ( - + )}
+ {createFor === key && ( + createAndAssign(u, body)} /> + )}
) })} + {visible < filtered.length && ( + + )} -

Gelernt ({learned.length})

- {learned.map((m) => ( +

+ Gelernt ({learned.length}){' '} + +

+ {showLearned && learned.map((m) => (
{m.source_name_raw}
→ {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` : ''}
@@ -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 && ( - setRecipeFor(null)} - onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} - /> + setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} /> )}
) diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index b62fab8..48827c4 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -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 (

Ernährung

- {unmappedCount > 0 && ( + {(unmappedCount > 0 || unmappedTotal > 0) && (
- {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.`} + {' '} @@ -968,11 +981,16 @@ export default function NutritionPage() { )} - {inputTab==='map' && } + {inputTab==='map' && ( + + )} - {loading &&
} + {loading && inputTab !== 'map' &&
} - {!loading && !hasData && ( + {!loading && !hasData && inputTab !== 'map' && (

Noch keine Ernährungsdaten

Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.

@@ -980,7 +998,7 @@ export default function NutritionPage() { )} {/* Analysis Section */} - {!loading && hasData && ( + {!loading && hasData && inputTab !== 'map' && ( <> diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 24a4da7..4661658 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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)),