diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 6c80c94..0546193 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. 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. +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). Im Listendialog sieht man pro Zutat, ob ein Mapping fehlt, und kann dort bestätigen oder suchen — zusätzlich zum Tab Zuordnen. Die Zutatensuche zeigt zuerst Namen aus importierten/angelegten Listen, dann eigene Lebensmittel, dann den BLS. „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 88d86bf..1481d8a 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -42,6 +42,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - `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. +- `GET /nutrition/ingredient-names` — Distinct-Zutatennamen aller Listen inkl. Mapping-Status. Listendialog mappt manuell (`upsertMyFoodMapping`) an der Zeile. - 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 e048167..cc76692 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):** 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}`. +- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **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_recipes.py b/backend/data_layer/food_recipes.py index 6e492e7..6924ae8 100644 --- a/backend/data_layer/food_recipes.py +++ b/backend/data_layer/food_recipes.py @@ -146,6 +146,36 @@ def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]: return linked, list(dates) +def list_known_ingredient_names(cur, profile_id: str, limit: int = 2000) -> list[dict[str, Any]]: + """Distinct list-ingredient names, with mapping if already learned.""" + cur.execute( + """ + SELECT i.source_name_raw, i.source_name_normalized, + MIN(m.food_id::text) AS food_id, + MIN(f.name_de) AS food_name_de, + MIN(f.bls_code) AS bls_code, + BOOL_OR(m.id IS NOT NULL) AS mapped + 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 food_catalog f ON f.id = m.food_id + WHERE r.profile_id = %s + GROUP BY i.source_name_raw, i.source_name_normalized + ORDER BY i.source_name_normalized + LIMIT %s + """, + (profile_id, limit), + ) + out = [] + for row in cur.fetchall(): + rec = dict(row) + rec["mapped"] = bool(rec.get("mapped")) + out.append(rec) + return out + + def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> list[dict[str, Any]]: cur.execute( """ diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 0df8b5d..e8d1ca8 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -377,6 +377,17 @@ def list_unmapped_foods( return list_unmapped_payload(get_cursor(conn), pid, since_days, count_only, meta) +@router.get("/ingredient-names") +def list_food_ingredient_names( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_recipes import list_known_ingredient_names + pid = get_pid(x_profile_id) + with get_db() as conn: + return list_known_ingredient_names(get_cursor(conn), pid) + + @router.get("/recipes") def list_food_recipes( brief: bool = False, diff --git a/backend/tests/test_food_recipes.py b/backend/tests/test_food_recipes.py index 166853a..a6fc40d 100644 --- a/backend/tests/test_food_recipes.py +++ b/backend/tests/test_food_recipes.py @@ -1,4 +1,24 @@ -from data_layer.food_recipes import save_recipe +from data_layer.food_recipes import list_known_ingredient_names, save_recipe + + +class _FakeCur: + def __init__(self, rows): + self.rows = rows + + def execute(self, sql, params=None): + return None + + def fetchall(self): + return self.rows + + +def test_known_ingredient_names_flags_mapping(): + rows = list_known_ingredient_names(_FakeCur([ + {"source_name_raw": "Hafer", "source_name_normalized": "hafer", "food_id": "1", "food_name_de": "Haferflocken", "bls_code": "B123", "mapped": True}, + {"source_name_raw": "Salz", "source_name_normalized": "salz", "food_id": None, "food_name_de": None, "bls_code": None, "mapped": False}, + ]), "p") + assert rows[0]["mapped"] is True + assert rows[1]["mapped"] is False def test_save_recipe_requires_name(): diff --git a/backend/version.py b/backend/version.py index b403a60..84f4f38 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.4", # Listen-Tab; Verknüpfen trotz Mengen-Prefix; Liste wählen ohne Vorfilter + "nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS "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/frontend/src/components/FoodRecipeDialog.jsx b/frontend/src/components/FoodRecipeDialog.jsx index d1a0b4e..01a8bb5 100644 --- a/frontend/src/components/FoodRecipeDialog.jsx +++ b/frontend/src/components/FoodRecipeDialog.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' +import FoodSearchModal from './FoodSearchModal' const FALLBACK_UNITS = [ { id: 'g', label: 'g', default_g: 1, needs_unit_map: false }, @@ -73,10 +74,11 @@ function fromSavedIngredient(i, mapped) { function mappingFor(name, learned) { const n = (name || '').trim().toLowerCase() if (!n) return null + const stripped = n.replace(/^\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim() return (learned || []).find((m) => { const raw = (m.source_name_raw || '').toLowerCase() const norm = (m.source_name_normalized || '').toLowerCase() - return raw === n || norm === n + return raw === n || norm === n || raw === stripped || norm === stripped }) || null } @@ -89,6 +91,15 @@ function filterLearned(term, learned) { }).slice(0, 8) } +function filterKnownIngredients(term, names) { + const t = (term || '').trim().toLowerCase() + if (t.length < 2) return [] + return (names || []).filter((n) => { + const hay = `${n.source_name_raw || ''} ${n.source_name_normalized || ''} ${n.food_name_de || ''}`.toLowerCase() + return hay.includes(t) + }).slice(0, 8) +} + export default function FoodRecipeDialog({ title, defaultName, @@ -114,13 +125,48 @@ export default function FoodRecipeDialog({ const [error, setError] = useState(null) const [saving, setSaving] = useState(false) const [localLearned, setLocalLearned] = useState(learned || []) + const [knownNames, setKnownNames] = useState([]) + const [suggestions, setSuggestions] = useState({}) + const [mapFor, setMapFor] = useState(null) const nameRef = useRef(null) const qtyRefs = useRef({}) const timer = useRef(null) const abortRef = useRef(null) const seqRef = useRef(0) - useEffect(() => { setLocalLearned(learned || []) }, [learned]) + useEffect(() => { + setLocalLearned(learned || []) + setIngs((list) => list.map((ing) => { + const m = mappingFor(ing.source_name_raw, learned) + if (!m) return ing + return { + ...ing, + foodId: ing.foodId || m.food_id, + mappedLabel: ing.mappedLabel || m.food_name_de, + gramsPerUnit: ing.gramsPerUnit || m.grams_per_unit || '', + } + })) + }, [learned]) + + useEffect(() => { + api.listNutritionIngredientNames().then((rows) => { + setKnownNames(Array.isArray(rows) ? rows : []) + }).catch(() => {}) + }, []) + + const unmappedKey = ings + .filter((i) => i.source_name_raw && !mappingFor(i.source_name_raw, localLearned) && !i.foodId) + .map((i) => i.source_name_raw) + .join('\n') + + useEffect(() => { + const names = unmappedKey ? unmappedKey.split('\n') : [] + if (!names.length) return + api.suggestFoodsBatch(names, 3).then((packed) => { + if (!packed || typeof packed !== 'object') return + setSuggestions((prev) => ({ ...prev, ...packed })) + }).catch(() => {}) + }, [unmappedKey]) useEffect(() => { api.listFoodUnits().then((list) => { @@ -140,8 +186,13 @@ export default function FoodRecipeDialog({ }, []) const mappedHits = filterLearned(q, localLearned) - const mappedNames = new Set(mappedHits.map((m) => (m.food_name_de || '').toLowerCase())) - const catalogOnly = catalogHits.filter((h) => !mappedNames.has((h.name_de || '').toLowerCase())) + const listHits = filterKnownIngredients(q, knownNames).filter((n) => { + const raw = (n.source_name_raw || '').toLowerCase() + return !mappedHits.some((m) => (m.source_name_raw || '').toLowerCase() === raw) + }) + const mappedFoodNames = new Set(mappedHits.map((m) => (m.food_name_de || '').toLowerCase())) + const userHits = catalogHits.filter((h) => h.catalog_kind && h.catalog_kind !== 'official_bls') + const blsHits = catalogHits.filter((h) => (!h.catalog_kind || h.catalog_kind === 'official_bls') && !mappedFoodNames.has((h.name_de || '').toLowerCase())) const runCatalog = async (term) => { const query = (term || '').trim() @@ -216,6 +267,41 @@ export default function FoodRecipeDialog({ mappedLabel: h.name_de, }) + const addKnown = (n) => addIngredient({ + source_name_raw: n.source_name_raw, + foodId: n.food_id || null, + mappedLabel: n.food_name_de || '', + }) + + const rememberMapping = (sourceName, food, extras = {}) => { + setLocalLearned((list) => [{ + id: food.mapping_id, + source_name_raw: sourceName, + source_name_normalized: food.source_name_normalized, + food_id: food.id || food.food_id, + food_name_de: food.name_de || food.food_name_de, + bls_code: food.bls_code, + grams_per_unit: extras.grams_per_unit, + source_unit: extras.source_unit, + }, ...list.filter((m) => (m.source_name_raw || '').toLowerCase() !== sourceName.toLowerCase())]) + setIngs((list) => list.map((x) => ( + (x.source_name_raw || '').toLowerCase() === sourceName.toLowerCase() + ? { ...x, foodId: food.id || food.food_id, mappedLabel: food.name_de || food.food_name_de || '' } + : x + ))) + } + + const assignIngredient = async (sourceName, food, extras = {}) => { + const res = await api.upsertMyFoodMapping({ + source_name: sourceName, + food_id: food.id || food.food_id, + grams_per_unit: extras.grams_per_unit || null, + source_unit: extras.source_unit || null, + }) + rememberMapping(sourceName, { ...food, ...res }, extras) + setMapFor(null) + } + const addFreeText = () => { const raw = q.trim() if (!raw) return @@ -317,7 +403,7 @@ export default function FoodRecipeDialog({
- Roh-Kombination ohne Kochen (z. B. Müsli). Zutaten suchen, Menge und Einheit angeben. Gramm ist die Standardeinheit — EL, TL, Prise usw. werden umgerechnet, sobald der Faktor bekannt ist. Gekochte Gerichte bleiben in Tandoor. + Roh-Kombination ohne Kochen (z. B. Müsli). Zuerst Namen aus deinen Listen, dann eigene Lebensmittel, dann BLS. Jede Zutat kannst du hier dem Katalog zuordnen — offene Zutaten bleiben rot markiert.
{error &&Katalog…
} - {catalogOnly.length > 0 && ( -Zutaten ({ings.length})
++ Zutaten ({ings.length} + {ings.filter((i) => !mappingFor(i.source_name_raw, localLearned) && !i.foodId).length + ? ` · ${ings.filter((i) => !mappingFor(i.source_name_raw, localLearned) && !i.foodId).length} ohne Mapping` + : ings.length ? ' · alle zugeordnet' : ''}) +
{ings.length === 0 && (Noch keine Zutaten — oben suchen und übernehmen.
)} {ings.map((ing, i) => { - const mapped = mappingFor(ing.source_name_raw, localLearned) || (ing.foodId ? { food_name_de: ing.mappedLabel } : null) + const mapped = mappingFor(ing.source_name_raw, localLearned) || (ing.foodId ? { food_name_de: ing.mappedLabel, food_id: ing.foodId } : null) + const packed = suggestions[ing.source_name_raw] || {} + const hints = packed.suggestions || [] const meta = unitMeta(units, ing.unit) const approx = gramsHint(ing, units) const needsFactor = Boolean(meta?.needs_unit_map) && !approx @@ -456,9 +582,24 @@ export default function FoodRecipeDialog({ {' '}g )} -