From b37005be01ebd6a074a2869183eb104cbd1de845 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 13 Sep 2026 12:40:53 +0200 Subject: [PATCH] feat: Listen-Tab und sichtbare Zutatenlisten-Verwaltung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listen anlegen, bearbeiten und importieren liegt unter Ernährung. Tagebuchzeilen werden auch mit Mengen-Prefix verknüpft. Co-authored-by: Cursor --- .claude/docs/functional/BLS_FOOD_REFERENCE.md | 2 +- .claude/docs/technical/BLS_FOOD_REFERENCE.md | 1 + CLAUDE.md | 2 +- backend/data_layer/food_mapping.py | 45 +++++ backend/data_layer/food_recipes.py | 77 ++++++--- backend/routers/nutrition.py | 13 +- backend/tests/test_food_mapping.py | 9 + backend/version.py | 2 +- docs/issues/issue-bls-food-mapping.md | 1 + frontend/src/components/FoodRecipeEditor.jsx | 154 ++++++++++++++---- frontend/src/components/NutritionFoodMap.jsx | 54 ++++-- frontend/src/pages/NutritionPage.jsx | 20 ++- 12 files changed, 307 insertions(+), 73 deletions(-) diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 51209eb..6c80c94 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) -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, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. 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. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund. +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, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. 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. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund. Zutatenlisten liegen im Tab **Listen** (sichtbar, durchsuchbar, bearbeitbar). „Andere Liste wählen“ verknüpft eine Tagebuchzeile mit einer bestehenden Liste; der Dialog zeigt alle Listen, nicht nur Namens-Treffer. Nach dem Verknüpfen verschwindet die Zeile unter Zuordnen — Kennzeichnung „Liste: …“, solange sie noch offen ist. ## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor) diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index 994ea31..88d86bf 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -41,6 +41,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g). - `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `meta=true` liefert `{items, count, total, since_days}`. Listenzutaten-Datum: letzte `recipe_id`-Nutzung oder `food_recipes.updated_at`/`created_at` — kein Join über den Listennamen auf alle Tagebuchzeilen. Migration **068**: Partial-Index unmapped-open + `(profile_id, recipe_id, date)`. - `GET /nutrition/recipes?brief=true` — Listen ohne Zutaten, nur `ingredient_count`. `GET /nutrition/recipes/{id}` lädt eine Liste. FDDB-Listenimport antwortet nach Upsert; `rebuild_daily_nutrients` im Hintergrund (`rebuild_pending`). +- Listen-Verknüpfung (`apply` / `link_recipes_to_items`) matcht über `names_match_list` (Mengen-Prefix in gespeichertem `source_name_normalized` zählt). Unmapped-Zeilen bekommen `matching_recipe_id` + `matching_recipe_name`. Frontend: Tab Ernährung → Listen. - 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 cffcbc3..e048167 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ frontend/src/ - **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“. - **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten. - **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. -- **Listen / Kombinationen (Mitai):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`. +- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Tagebuchzeile per Name oder „Andere Liste wählen“ verknüpfen; Eintrag verschwindet danach, Zutaten bleiben offen. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`. - **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/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index 3911025..2bede24 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -31,6 +31,28 @@ def normalize_food_name(raw: str | None) -> str: return s.lower() +def item_name_keys(raw: str | None, stored_norm: str | None = None) -> set[str]: + """Compare diary names to list names despite stored quantity prefixes.""" + keys: set[str] = set() + for v in (raw, stored_norm): + if not v: + continue + text = str(v).strip() + if text: + keys.add(text.lower()) + norm = normalize_food_name(text) + if norm: + keys.add(norm) + return keys + + +def names_match_list(raw: str | None, stored_norm: str | None, list_norm: str | None) -> bool: + target = normalize_food_name(list_norm) if list_norm else "" + if not target: + return False + return target in item_name_keys(raw, stored_norm) + + _LIST_COMMA_RE = re.compile(r",(?!\s*\d)") @@ -130,6 +152,29 @@ def fetch_unmapped_rows(cur, profile_id: str) -> list[dict[str, Any]]: (profile_id,), ) diary = [dict(r) | {"kind": "diary"} for r in cur.fetchall()] + cur.execute( + """ + SELECT id, name_raw, name_normalized + FROM food_recipes + WHERE profile_id = %s + """, + (profile_id,), + ) + lists = [dict(r) for r in cur.fetchall()] + for row in diary: + keys = item_name_keys(row.get("source_name_raw"), row.get("source_name_normalized")) + hit = next( + ( + rec + for rec in lists + if rec.get("name_normalized") in keys + or normalize_food_name(rec.get("name_raw")) in keys + ), + None, + ) + if hit: + row["matching_recipe_id"] = str(hit["id"]) + row["matching_recipe_name"] = hit["name_raw"] cur.execute( """ SELECT i.source_name_raw, i.source_name_normalized, diff --git a/backend/data_layer/food_recipes.py b/backend/data_layer/food_recipes.py index 6d5cc13..6e492e7 100644 --- a/backend/data_layer/food_recipes.py +++ b/backend/data_layer/food_recipes.py @@ -6,6 +6,7 @@ from typing import Any from data_layer.food_mapping import ( get_food_mapping_with_cursor, + names_match_list, normalize_food_name, resolve_quantity, ) @@ -100,29 +101,49 @@ def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[ def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]: + cur.execute( + "SELECT id, name_raw, name_normalized FROM food_recipes WHERE profile_id = %s", + (profile_id,), + ) + recipes = [dict(r) for r in cur.fetchall()] + if not recipes: + return 0, [] cur.execute( """ - SELECT DISTINCT i.date::text AS date - FROM nutrition_items i - JOIN food_recipes r ON r.profile_id = i.profile_id - AND i.source_name_normalized = r.name_normalized - WHERE i.profile_id = %s AND i.food_id IS NULL + SELECT id, date::text AS date, source_name_raw, source_name_normalized + FROM nutrition_items + WHERE profile_id = %s AND food_id IS NULL AND recipe_id IS NULL """, (profile_id,), ) - dates = [r["date"] for r in cur.fetchall()] - cur.execute( - """ - UPDATE nutrition_items i - SET recipe_id = r.id, updated_at = NOW() - FROM food_recipes r - WHERE i.profile_id = %s AND r.profile_id = %s - AND i.source_name_normalized = r.name_normalized - AND i.food_id IS NULL - """, - (profile_id, profile_id), - ) - return cur.rowcount or 0, dates + by_rid: dict[str, list[str]] = {} + dates: set[str] = set() + for item in cur.fetchall(): + rid = next( + ( + str(rec["id"]) + for rec in recipes + if names_match_list(item.get("source_name_raw"), item.get("source_name_normalized"), rec.get("name_normalized")) + ), + None, + ) + if not rid: + continue + by_rid.setdefault(rid, []).append(str(item["id"])) + if item.get("date"): + dates.add(str(item["date"])) + linked = 0 + for rid, ids in by_rid.items(): + cur.execute( + """ + UPDATE nutrition_items + SET recipe_id = %s, updated_at = NOW() + WHERE id = ANY(%s::uuid[]) + """, + (rid, ids), + ) + linked += cur.rowcount or 0 + return linked, list(dates) def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> list[dict[str, Any]]: @@ -292,13 +313,29 @@ def delete_recipe(cur, profile_id: str, recipe_id: str) -> None: def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int: + target = normalize_food_name(source_name_normalized) or (source_name_normalized or "") + cur.execute( + """ + SELECT id, source_name_raw, source_name_normalized + FROM nutrition_items + WHERE profile_id = %s AND food_id IS NULL + """, + (profile_id,), + ) + ids = [ + str(row["id"]) + for row in cur.fetchall() + if names_match_list(row.get("source_name_raw"), row.get("source_name_normalized"), target) + ] + if not ids: + return 0 cur.execute( """ UPDATE nutrition_items SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW() - WHERE profile_id = %s AND source_name_normalized = %s + WHERE id = ANY(%s::uuid[]) """, - (recipe_id, profile_id, source_name_normalized), + (recipe_id, ids), ) return cur.rowcount or 0 diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 7ff2c26..0df8b5d 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -504,7 +504,18 @@ def apply_recipe_name( if not cur.fetchone(): raise HTTPException(404, "Rezept nicht gefunden") n = apply_recipe_to_items(cur, pid, norm, recipe_id) - for d in dates_for_normalized_name(cur, pid, norm): + cur.execute( + """ + SELECT DISTINCT date::text AS date + FROM nutrition_items + WHERE profile_id = %s AND recipe_id = %s + """, + (pid, recipe_id), + ) + days = [r["date"] for r in cur.fetchall()] + if not days: + days = dates_for_normalized_name(cur, pid, norm) + for d in days: rebuild_daily_nutrients(cur, pid, d) return {"ok": True, "items_updated": n} diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index a3d7b8c..ab709ea 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -7,6 +7,7 @@ from data_layer.food_mapping import ( list_quantity_units, list_unmapped_payload, merge_unmapped_rows, + names_match_list, normalize_food_name, parse_quantity, parse_quantity_g, @@ -104,6 +105,7 @@ def test_fetch_unmapped_keeps_ingredient_dates_and_skips_mapped(): cur = _FakeCur([ [{"source_name_normalized": "haferflocken"}], [], + [], [ { "source_name_raw": "Salz", @@ -129,8 +131,15 @@ def test_fetch_unmapped_keeps_ingredient_dates_and_skips_mapped(): assert rows[0]["kind"] == "recipe_ingredient" +def test_list_name_matches_despite_quantity_prefix(): + assert names_match_list("50 g Müsli Mix", "50 g müsli mix", "müsli mix") + assert names_match_list("Müsli Mix", "müsli mix", "müsli mix") + assert not names_match_list("Haferflocken", "haferflocken", "müsli mix") + + def test_unmapped_meta_includes_recent_ingredients(): batches = [ + [], [], [], [{ diff --git a/backend/version.py b/backend/version.py index 4065cb3..b403a60 100644 --- a/backend/version.py +++ b/backend/version.py @@ -20,7 +20,7 @@ MODULE_VERSIONS = { "circumference": "1.0.1", "caliper": "1.0.1", "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement - "nutrition": "1.3.3", # Unmapped ohne Kreuzjoin; Listen-Import Rebuild async + "nutrition": "1.3.4", # Listen-Tab; Verknüpfen trotz Mengen-Prefix; Liste wählen ohne Vorfilter "tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet) "bls": "1.0.5", # GET /bls/units "photos": "1.0.0", diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md index db518bf..1dead09 100644 --- a/docs/issues/issue-bls-food-mapping.md +++ b/docs/issues/issue-bls-food-mapping.md @@ -24,3 +24,4 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende - Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import) - Folge: Tandoor-Gerichte live lesen + BLS-Mapping + `cooked_yield_g` — kein Zutaten-Vollsync - Fix 2026-09-12: Offene Liste war leer, weil der 28-Tage-Filter Zutaten ohne `last_date` verwarf und die Unmapped-Query Listen×Tagebuch kreuzte (Minuten). Import wartete auf synchrone Tages-Rebuilds. +- Fix 2026-09-13: Listen-Tab (CRUD + FDDB-Import); „Liste wählen“ ohne Vorfilter auf den Eintragsnamen; Verknüpfen trotz Mengen-Prefix; Kennzeichnung „Liste: …“. diff --git a/frontend/src/components/FoodRecipeEditor.jsx b/frontend/src/components/FoodRecipeEditor.jsx index 5b3a8f4..26dcce3 100644 --- a/frontend/src/components/FoodRecipeEditor.jsx +++ b/frontend/src/components/FoodRecipeEditor.jsx @@ -1,76 +1,170 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' import FoodRecipeDialog from './FoodRecipeDialog' -export default function FoodRecipeEditor({ recipes, learned, onChanged }) { - const [open, setOpen] = useState(false) +export default function FoodRecipeEditor({ learned: learnedProp, highlightId, onChanged }) { + const [recipes, setRecipes] = useState([]) + const [learned, setLearned] = useState(learnedProp || []) + const [q, setQ] = useState('') + const [visible, setVisible] = useState(40) const [editing, setEditing] = useState(null) const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [importing, setImporting] = useState(false) + const [loading, setLoading] = useState(true) + const listRef = useRef(null) + const highlightRef = useRef(null) + const openedHighlight = useRef(null) + + const load = async () => { + try { + const [r, m] = await Promise.all([ + api.listNutritionRecipes(true), + learnedProp ? Promise.resolve(learnedProp) : api.listMyFoodMappings().catch(() => []), + ]) + setRecipes(Array.isArray(r) ? r : []) + setLearned(Array.isArray(m) ? m : (learnedProp || [])) + setError(null) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { load() }, []) + useEffect(() => { if (learnedProp) setLearned(learnedProp) }, [learnedProp]) const startNew = () => { setEditing('new') setError(null) - setOpen(true) } const startEdit = async (r) => { setError(null) try { - const full = (r.ingredients && r.ingredients.length) || r.ingredient_count === 0 + const full = (r.ingredients && r.ingredients.length) ? r : await api.getNutritionRecipe(r.id) setEditing(full) - setOpen(true) } catch (e) { setError(e.message) } } + useEffect(() => { + if (!highlightId || !recipes.length) return + highlightRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + if (openedHighlight.current === highlightId) return + const r = recipes.find((x) => x.id === highlightId) + if (!r) return + openedHighlight.current = highlightId + startEdit(r) + }, [highlightId, recipes]) + + const filtered = recipes.filter((r) => { + const term = q.trim().toLowerCase() + if (!term) return true + return `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase().includes(term) + }) + const shown = filtered.slice(0, visible) + const save = async (body) => { if (editing === 'new') await api.createNutritionRecipe(body) else await api.updateNutritionRecipe(editing.id, body) setEditing(null) + await load() onChanged?.() } const remove = async (id) => { - if (!confirm('Liste wirklich löschen?')) return + if (!confirm('Liste wirklich löschen? Tagebuchzeilen fallen dann wieder unter Zuordnen.')) return try { await api.deleteNutritionRecipe(id) if (editing && editing !== 'new' && editing.id === id) setEditing(null) + await load() onChanged?.() } catch (e) { setError(e.message) } } + const importLists = async (file) => { + if (!file) return + setImporting(true) + setError(null) + try { + const res = await api.importFddbLists(file) + await load() + onChanged?.() + const pending = res.rebuild_pending ? ' Tageswerte werden im Hintergrund nachgerechnet.' : '' + setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.${pending}`) + } catch (e) { + setError(e.message) + } finally { + setImporting(false) + } + } + return ( -
-
-

Eigene Listen / Kombinationen ({recipes.length})

- +
- {open && ( -
- {error &&
{error}
} - - {recipes.map((r) => ( -
-
{r.name_raw}
-
- {r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen - {r.source === 'manual' ? ' · selbst angelegt' : ''} -
-
- - -
-
- ))} -
+ +

{loading ? 'Lade Listen…' : `${recipes.length} Listen`}

+ {recipes.length > 8 && ( + { setQ(e.target.value); setVisible(40) }} + /> )} + {!loading && recipes.length === 0 && ( +

Noch keine Listen. FDDB-Export (`lists_*.csv`) importieren oder eine neue anlegen.

+ )} + {shown.map((r) => ( +
+
{r.name_raw}
+
+ {r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen + {r.source === 'fddb_list' ? ' · FDDB' : r.source === 'manual' ? ' · selbst angelegt' : ''} +
+
+ + +
+
+ ))} + {visible < filtered.length && ( + + )} + {editing === 'new' && ( { const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase() return !q.trim() || hay.includes(q.trim().toLowerCase()) @@ -53,14 +53,19 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) { }} >
-

Eigene Liste wählen

+

Liste verknüpfen

- setQ(e.target.value)} /> +

+ Diese Tagebuchzeile{hint ? ` („${hint}“)` : ''} einer bestehenden Zutatenliste zuordnen. + Der Eintrag verschwindet danach hier; die Zutaten der Liste ordnest du einzeln zu. +

+ setQ(e.target.value)} />
- {filtered.length === 0 &&

Keine passende Liste.

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

Noch keine Listen. Unter „Listen“ anlegen oder FDDB importieren.

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

Keine Liste passt zum Filter.

} {filtered.map((r) => ( - { load(); onChanged?.() }} /> +
+ + +
+

+ {recipes.length ? `${recipes.length} Listen vorhanden.` : 'Noch keine Listen.'} Anlegen, bearbeiten und FDDB-Import unter Listen. +

{ const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
@@ -414,9 +427,15 @@ export default function NutritionFoodMap({ onChanged, onMapped }) {
{suggestQuery(u.source_name_raw) || u.source_name_raw}
{u.kind === 'recipe_ingredient' ? 'Listenzutat' : `${u.count}×`} + {u.matching_recipe_id ? ` · Liste${u.matching_recipe_name ? `: ${u.matching_recipe_name}` : ''}` : ''} {u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''} {u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
+ {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( +
+ Entspricht einer Zutatenliste — verknüpfen, dann die Zutaten zuordnen. +
+ )}
{(u.ambiguous || suggestions.length > 1) && ( @@ -450,10 +469,13 @@ export default function NutritionFoodMap({ onChanged, onMapped }) { {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( - + )} - {u.kind !== 'recipe_ingredient' && recipes.length > 0 && ( - + {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( + + )} + {u.kind !== 'recipe_ingredient' && ( + )}
{createFor === key && ( diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 48827c4..e26d179 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react' import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react' import UsageBadge from '../components/UsageBadge' import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap' +import FoodRecipeEditor from '../components/FoodRecipeEditor' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter @@ -900,6 +901,7 @@ export default function NutritionPage() { const [nutritionUsage, setNutritionUsage] = useState(null) const [unmappedCount, setUnmappedCount] = useState(0) const [unmappedTotal, setUnmappedTotal] = useState(0) + const [focusListId, setFocusListId] = useState(null) const loadUsage = () => { nutritionApi.getFeatureUsage().then(features => { @@ -965,6 +967,9 @@ export default function NutritionPage() { +
{/* Entry Form */} @@ -985,12 +990,21 @@ export default function NutritionPage() { { setFocusListId(id || null); setInputTab('lists') }} + onCreatedList={(rec) => { setFocusListId(rec?.id || null); setInputTab('lists') }} /> )} - {loading && inputTab !== 'map' &&
} + {inputTab==='lists' && ( + + )} - {!loading && !hasData && inputTab !== 'map' && ( + {loading && inputTab !== 'map' && inputTab !== 'lists' &&
} + + {!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && (

Noch keine Ernährungsdaten

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

@@ -998,7 +1012,7 @@ export default function NutritionPage() { )} {/* Analysis Section */} - {!loading && hasData && inputTab !== 'map' && ( + {!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && ( <>