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
Schließen
setQ(e.target.value)} />
{filtered.length === 0 &&
Kein passendes Rezept.
}
{filtered.map((r) => (
onPick(r.id)}>
{r.name_raw}
{(r.ingredients || []).length} Zutaten
))}
)
}
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)?
setKind('food')}>Lebensmittel
{allowRecipe && (
setKind('recipe')}>Rezept
)}
)
}
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" />
setKind(null)}>Zurück
onCreateFood(foodCreatePayload(name, macros, extras, servingG))}
>
Anlegen und zuordnen
)
}
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) }} />
listRef.current?.click()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
{ load(); onChanged?.() }} />
{ const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
Zuordnungen & Listen exportieren
bundleRef.current?.click()}>Zuordnungen & Listen importieren
Offen ({filtered.length}{sinceDays && totalOpen > unmapped.length ? ` von ${totalOpen}` : filtered.length !== unmapped.length ? ` / ${unmapped.length}` : ''})
{withSuggest} mit Vorschlag
{PERIODS.map((p) => (
{ setSinceDays(p.days); setVisible(80); setQ('') }}
>
{p.label}
))}
{ setQ(e.target.value); setVisible(80) }} />
{[['all', 'Alle Treffer'], ['suggested', 'Mit Vorschlag'], ['none', 'Ohne Vorschlag']].map(([id, label]) => (
{ setFilter(id); setVisible(80) }}>{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'}
assign(u, best.id, {}, best)}>
Bestätigen
)}
{suggestions.slice(1).map((s) => (
oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''}
assign(u, s.id, {}, s)}>Übernehmen
))}
{!best &&
Kein Katalogvorschlag — selbst anlegen.
}
setCreateFor(createFor === key ? null : key)}>
{createFor === key ? 'Anlegen schließen' : (u.kind === 'recipe_ingredient' ? 'Neu anlegen' : 'Lebensmittel oder Rezept')}
setSearchFor(u)}>Mehr suchen
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Rezept
)}
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
setRecipeFor(u)}>Rezept wählen
)}
{createFor === key && (
createAndAssign(u, body)}
onCreateRecipe={(body) => createRecipeAndAssign(u, body)}
/>
)}
)
})}
{visible < filtered.length && (
setVisible((n) => n + 80)}>
Weitere {Math.min(80, filtered.length - visible)} zeigen
)}
Gelernt ({learned.length}){' '}
setShowLearned((v) => !v)}>
{showLearned ? 'Einklappen' : 'Anzeigen'}
{showLearned && learned.map((m) => (
{m.source_name_raw}
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
remove(m.id)}>Löschen
))}
{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 (
setMark('fasting')}>
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
setMark('incomplete')}>
{markType === 'incomplete' ? '✓ Lücke' : 'Lücke'}
)
}