diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py index a1106c5..056c128 100644 --- a/backend/csv_parser/executor.py +++ b/backend/csv_parser/executor.py @@ -11,7 +11,31 @@ from typing import Any import logging -from csv_parser.core import iter_csv_dict_rows, resolve_effective_csv_delimiter +from csv_parser.core import iter_csv_dict_rows, normalize_header_for_signature, resolve_effective_csv_delimiter + +_FOOD_NAME_HEADERS = frozenset({ + "bezeichnung", "lebensmittel", "food", "food_name", "name", "gericht", +}) +_QTY_HEADERS = frozenset({ + "menge", "quantity", "quantity_raw", "portion", "amount", "gramm", +}) + + +def guess_nutrition_item_fields(csv_row: dict[str, Any] | None) -> tuple[str | None, str | None]: + """FDDB-Spalten auch ohne Vorlagen-Mapping auf food_name / menge erkennen.""" + name = qty = None + for key, val in (csv_row or {}).items(): + if val is None: + continue + text = str(val).strip().strip('"') + if not text: + continue + norm = normalize_header_for_signature(str(key)) + if name is None and (norm in _FOOD_NAME_HEADERS or "bezeichnung" in norm): + name = text + if qty is None and (norm in _QTY_HEADERS or norm.startswith("menge")): + qty = text + return name, qty from csv_parser.import_row_processing import ( aggregate_mapped_rows, resolve_import_row_processing, @@ -181,6 +205,8 @@ def run_universal_csv_import( "error_details": error_details[:50], "new_entries": stats.get("new_entries", stats.get("inserted", 0)), "affected_ids": dict(affected_ids), + "items_written": stats.get("items_written", 0), + "unmapped_names": stats.get("unmapped_names", 0), } return out @@ -203,6 +229,11 @@ def _import_nutrition( for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header): rows_total += 1 mapped = build_row_after_mapping(csv_row, fm, tc, module="nutrition") + guessed_name, guessed_qty = guess_nutrition_item_fields(csv_row) + if not mapped.get("food_name") and guessed_name: + mapped["food_name"] = guessed_name + if not mapped.get("quantity_raw") and guessed_qty: + mapped["quantity_raw"] = guessed_qty d = coerce_date(mapped.get("date")) if d is None: error_details.append({"row": rows_total, "error": "Datum fehlt oder ungültig"}) @@ -246,6 +277,15 @@ def _import_nutrition( policy = get_import_policy(cur, profile_id) ingest = replace_csv_items_for_dates(cur, profile_id, item_rows, policy=policy) + cur.execute( + """ + SELECT COUNT(DISTINCT source_name_normalized) AS n + FROM nutrition_items + WHERE profile_id = %s AND food_id IS NULL + """, + (profile_id,), + ) + unmapped_row = cur.fetchone() or {} return { "rows_total": rows_total, "inserted": ingest.get("new_log_days", 0), @@ -253,6 +293,7 @@ def _import_nutrition( "skipped": skipped_groups, "new_entries": ingest.get("new_log_days", 0), "items_written": ingest.get("items_written", 0), + "unmapped_names": int(unmapped_row.get("n") or 0), "conflicts": ingest.get("conflicts") or [], "policy": ingest.get("policy"), } diff --git a/backend/csv_parser/mapping_suggest.py b/backend/csv_parser/mapping_suggest.py index 905bf44..9fcedfe 100644 --- a/backend/csv_parser/mapping_suggest.py +++ b/backend/csv_parser/mapping_suggest.py @@ -20,6 +20,8 @@ _MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = { "protein_g": frozenset({"protein", "eiwei", "eiweiss"}), "fat_g": frozenset({"fett", "fat", "lipid"}), "carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}), + "food_name": frozenset({"bezeichnung", "lebensmittel", "food", "gericht"}), + "quantity_raw": frozenset({"menge", "quantity", "portion", "gramm"}), }, "weight": { "date": frozenset({"datum", "date", "tag", "day", "zeit"}), diff --git a/backend/routers/csv_import.py b/backend/routers/csv_import.py index b5ccfb3..5c45625 100644 --- a/backend/routers/csv_import.py +++ b/backend/routers/csv_import.py @@ -712,6 +712,8 @@ async def csv_import_execute( "updated": result["rows_updated"], "skipped": result["rows_skipped"], "errors": result["rows_errors"], + "items_written": result.get("items_written", 0), + "unmapped_names": result.get("unmapped_names", 0), }, "error_details": result["error_details"], } diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 4ebde2d..d3bdede 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -227,7 +227,23 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No """, (pid, limit), ) - return [r2d(r) for r in cur.fetchall()] + rows = [r2d(r) for r in cur.fetchall()] + dates = [r["date"] for r in rows if r.get("date") is not None] + counts = {} + if dates: + cur.execute( + """ + SELECT date, COUNT(*) AS item_count + FROM nutrition_items + WHERE profile_id = %s AND date = ANY(%s) + GROUP BY date + """, + (pid, dates), + ) + counts = {str(r["date"]): int(r["item_count"] or 0) for r in cur.fetchall()} + for r in rows: + r["item_count"] = counts.get(str(r.get("date")), 0) + return rows @router.get("/by-date/{date}") diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index 39c11ed..df7d7bd 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -1,3 +1,4 @@ +from csv_parser.executor import guess_nutrition_item_fields from data_layer.food_mapping import normalize_food_name, parse_quantity_g from data_layer.nutrition_items import macros_differ @@ -13,6 +14,16 @@ def test_parse_quantity_g(): assert parse_quantity_g("1 Stück") is None +def test_guess_fddb_bezeichnung_without_template_mapping(): + name, qty = guess_nutrition_item_fields({ + "bezeichnung": "50 g Hähnchen", + "menge": "50 g", + "kj": "800", + }) + assert name == "50 g Hähnchen" + assert qty == "50 g" + + 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/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 4f9a35b..15a0d8f 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -195,6 +195,49 @@ function EntryForm({ onSaved }) { } // ── Data Tab (Editable Entry List) ─────────────────────────────────────────── +function DayItems({ date }) { + const [items, setItems] = useState(null) + const [open, setOpen] = useState(false) + const [error, setError] = useState(null) + + const toggle = async () => { + if (open) { + setOpen(false) + return + } + if (!items) { + try { + setItems(await nutritionApi.listNutritionItems(date)) + } catch (e) { + setError(e.message) + return + } + } + setOpen(true) + } + + return ( +
{error}
} + {open && Array.isArray(items) && ( +