import { useEffect, useMemo, useRef, useState } from 'react' import { api } from '../utils/api' import FoodSearchModal from './FoodSearchModal' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' import FoodRecipeEditor, { RecipeForm } from './FoodRecipeEditor' 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() const comma = s.indexOf(',') if (comma > 2) s = s.slice(0, comma).trim() return s } function RecipePickModal({ recipes, sourceName, onPick, onClose }) { const [q, setQ] = useState(suggestQuery(sourceName)) const filtered = recipes.filter((r) => { const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase() return !q.trim() || hay.includes(q.trim().toLowerCase()) }) return (
e.stopPropagation()} style={{ width: '100%', maxWidth: 480, maxHeight: 'min(88vh, 560px)', background: 'var(--surface)', borderRadius: 16, boxShadow: '0 8px 32px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column', }} >

Eigenes Rezept wählen

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

Kein passendes Rezept.

} {filtered.map((r) => ( ))}
) } function InlineCreate({ defaultName, disabled, learned, allowRecipe, onCreateFood, onCreateRecipe }) { const [kind, setKind] = useState(allowRecipe ? null : 'food') const [name, setName] = useState(defaultName || '') const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' }) const [extras, setExtras] = useState([]) const [servingG, setServingG] = useState('100') if (!kind) { return (

Lebensmittel (ein Eintrag im Katalog) oder Rezept (mehrere Zutaten)?

{allowRecipe && ( )}
) } if (kind === 'recipe') { return ( setKind(null)} onSave={onCreateRecipe} /> ) } return (

Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.

setName(e.target.value)} placeholder="Name" />
) } export default function NutritionFoodMap({ onChanged, onMapped }) { const [unmapped, setUnmapped] = useState([]) const [learned, setLearned] = useState([]) const [recipes, setRecipes] = useState([]) const [error, setError] = useState(null) const [notice, setNotice] = useState(null) 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, counts] = await Promise.all([ api.listUnmappedFoods(sinceDays), api.listMyFoodMappings(), api.listNutritionRecipes().catch(() => []), api.listUnmappedFoodCount(sinceDays).catch(() => null), ]) if (gen !== loadGen.current) return 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) { if (gen !== loadGen.current) return setError(e.message) } } useEffect(() => { load() }, [sinceDays]) 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 { 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) 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 { setSaving(null) } } 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 createRecipeAndAssign = async (row, body) => { setSaving(row.source_name_raw) setError(null) try { const rec = await api.createNutritionRecipe({ ...body, source: 'manual' }) await api.applyNutritionRecipe(rec.id, row.source_name_raw) setCreateFor(null) setSearchFor(null) setNotice(`Rezept angelegt. Offene Zutaten erscheinen in der Liste.`) await load() onMapped?.() } catch (e) { setError(e.message) throw e } finally { setSaving(null) } } const applyRecipe = async (sourceName, recipeId) => { setSaving(sourceName) setError(null) try { await api.applyNutritionRecipe(recipeId, sourceName) setRecipeFor(null) setNotice('Rezept verknüpft. Offene Zutaten erscheinen in der Liste.') await load() onMapped?.() } catch (e) { setError(e.message) } finally { setSaving(null) } } const remove = async (id) => { 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) { setError(e.message) } } const exportBundle = async () => { setBusy(true) setError(null) try { await api.exportFoodKnowledge() setNotice('Zuordnungen und Listen als JSON heruntergeladen.') } catch (e) { setError(e.message) } finally { setBusy(false) } } const importBundle = async (file) => { if (!file) return setBusy(true) setError(null) try { const res = await api.importFoodKnowledge(file) await load() onChanged?.() const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : '' setNotice(`${res.mappings || 0} Zuordnungen und ${(res.inserted || 0) + (res.updated || 0)} Listen übernommen${skip}.`) } catch (e) { setError(e.message) } finally { setBusy(false) } } const importLists = async (file) => { if (!file) return setImporting(true) setError(null) try { const res = await api.importFddbLists(file) await load() onChanged?.() setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.`) } catch (e) { setError(e.message) } finally { setImporting(false) } } const withSuggest = unmapped.filter((u) => (u.suggestions || []).length).length return (
Lebensmittel zuordnen

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) }} /> { load(); onChanged?.() }} /> { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />

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` : ''} {u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
{(u.ambiguous || suggestions.length > 1) && ( mehrere möglich )}
{best && (
→ {best.name_de} {best.bls_code ? ` · ${best.bls_code}` : ' · manuell'}
)} {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)} onCreateRecipe={(body) => createRecipeAndAssign(u, body)} /> )}
) })} {visible < filtered.length && ( )}

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)' : ''}
))} {searchFor && ( setSearchFor(null)} onSelect={(food, extras) => assign(searchFor, food.id, extras, food)} onCreateRecipe={searchFor.kind === 'recipe_ingredient' ? undefined : (body) => createRecipeAndAssign(searchFor, body)} /> )} {recipeFor && ( setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} /> )}
) } export function DayMarkButtons({ date, markType, onChanged }) { const setMark = async (type) => { try { if (markType === type) await api.deleteNutritionDayMark(date) else await api.putNutritionDayMark(date, { mark_type: type }) onChanged?.() } catch (e) { alert(e.message) } } return ( ) }