"use client"; import { ArrowPathIcon, CameraIcon, CheckCircleIcon, HandThumbDownIcon, HandThumbUpIcon, MagnifyingGlassIcon, PlusIcon, ScaleIcon, } from "@heroicons/react/24/outline"; import { useState } from "react"; import { parseNutritionLabel, PortionUnit, unitLabel } from "@/lib/hellth-data"; import { trpc } from "@/lib/trpc"; const portionUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"]; type FoodForm = { name: string; brand: string; category: string; kcalPer100g: string; proteinPer100g: string; carbsPer100g: string; fatPer100g: string; servingG: string; preferredUnit: PortionUnit; densityGPerMl: string; portionLabel: string; portionUnit: PortionUnit; portionAmount: string; portionGramsEquivalent: string; barcode: string; availableEverywhere: boolean; }; const emptyForm: FoodForm = { name: "", brand: "", category: "Sonstiges", kcalPer100g: "", proteinPer100g: "", carbsPer100g: "", fatPer100g: "", servingG: "100", preferredUnit: "g", densityGPerMl: "", portionLabel: "", portionUnit: "piece", portionAmount: "1", portionGramsEquivalent: "", barcode: "", availableEverywhere: true, }; function toNumber(value: string) { return Number(value.replace(",", ".")) || 0; } function formFromFood(food: Record): FoodForm { return { ...emptyForm, name: String(food.name ?? ""), brand: String(food.brand ?? ""), category: String(food.category ?? "Sonstiges"), kcalPer100g: food.kcalPer100g != null ? String(food.kcalPer100g) : "", proteinPer100g: food.proteinPer100g != null ? String(food.proteinPer100g) : "", carbsPer100g: food.carbsPer100g != null ? String(food.carbsPer100g) : "", fatPer100g: food.fatPer100g != null ? String(food.fatPer100g) : "", servingG: food.servingG != null ? String(food.servingG) : "100", barcode: String(food.barcode ?? ""), }; } export default function LebensmittelPage() { const utils = trpc.useUtils(); const [query, setQuery] = useState(""); const [form, setForm] = useState(emptyForm); const [barcode, setBarcode] = useState(""); const [ocrText, setOcrText] = useState(""); const [status, setStatus] = useState(null); const [ocrBusy, setOcrBusy] = useState(false); const [lookupBusy, setLookupBusy] = useState(false); const [sourceFoodId, setSourceFoodId] = useState(null); const [sourceQuery, setSourceQuery] = useState(""); const foodsQuery = trpc.health.listFoods.useQuery({ query }); const sourceSearchQuery = trpc.health.searchOpenFoodFacts.useQuery( { query: sourceQuery, pageSize: 8 }, { enabled: sourceQuery.trim().length >= 2 }, ); const saveFoodMutation = trpc.health.saveFood.useMutation({ onSuccess: async (result) => { await utils.health.listFoods.invalidate(); setForm(emptyForm); setStatus(`Lebensmittel gespeichert (${result.id}).`); }, }); const addNutritionSourceMutation = trpc.health.addNutritionSource.useMutation({ onSuccess: async (result) => { await utils.health.listFoods.invalidate(); setStatus(`Quelle gespeichert. Neuer Durchschnitt aus ${result.sourceCount} Quelle(n): ${result.average.kcalPer100g} kcal/100 g.`); }, }); const setFoodPreferenceMutation = trpc.health.setFoodPreference.useMutation({ onSuccess: async () => { await utils.health.listFoods.invalidate(); await utils.health.recipeRecommendations.invalidate(); }, }); const foods = foodsQuery.data ?? []; function saveFood(source: "manual" | "openfoodfacts" | "ocr" = "manual") { if (!form.name.trim()) return; saveFoodMutation.mutate({ name: form.name.trim(), brand: form.brand.trim() || null, category: form.category.trim() || "Sonstiges", kcalPer100g: toNumber(form.kcalPer100g), proteinPer100g: toNumber(form.proteinPer100g), carbsPer100g: toNumber(form.carbsPer100g), fatPer100g: toNumber(form.fatPer100g), servingG: toNumber(form.servingG) || 100, preferredUnit: form.preferredUnit, densityGPerMl: form.densityGPerMl.trim() ? toNumber(form.densityGPerMl) : null, barcode: form.barcode.trim() || null, source, availableEverywhere: form.availableEverywhere, portions: form.portionLabel.trim() ? [{ label: form.portionLabel.trim(), unit: form.portionUnit, amount: toNumber(form.portionAmount) || 1, gramsEquivalent: toNumber(form.portionGramsEquivalent) || toNumber(form.servingG) || 100, }] : [], }); } async function importFromOpenFoodFacts() { const cleanBarcode = barcode.replace(/\D/g, ""); if (!cleanBarcode) return; setLookupBusy(true); setStatus(null); try { const response = await fetch(`https://world.openfoodfacts.org/api/v2/product/${cleanBarcode}.json?fields=product_name,brands,categories,nutriments,serving_quantity`); const result = await response.json() as { status?: number; product?: { product_name?: string; brands?: string; categories?: string; serving_quantity?: number; nutriments?: Record; }; }; if (result.status !== 1 || !result.product) { setStatus("Kein Produkt für diesen Barcode gefunden."); return; } const nutriments = result.product.nutriments ?? {}; setForm(formFromFood({ name: result.product.product_name || "Unbenanntes Produkt", brand: result.product.brands?.split(",")[0]?.trim(), category: result.product.categories?.split(",")[0]?.trim() || "Supermarkt", kcalPer100g: nutriments["energy-kcal_100g"] ?? 0, proteinPer100g: nutriments.proteins_100g ?? 0, carbsPer100g: nutriments.carbohydrates_100g ?? 0, fatPer100g: nutriments.fat_100g ?? 0, servingG: result.product.serving_quantity ?? 100, barcode: cleanBarcode, })); setStatus("Open-Food-Facts-Daten geladen. Bitte prüfen und speichern."); } catch { setStatus("Open Food Facts konnte gerade nicht abgefragt werden."); } finally { setLookupBusy(false); } } async function readLabelImage(file: File) { setOcrBusy(true); setStatus("OCR läuft im Browser. Das kann einen Moment dauern."); try { const { recognize } = await import("tesseract.js"); const result = await recognize(file, "deu+eng"); const text = result.data.text; const parsed = parseNutritionLabel(text); setOcrText(text); setForm((current) => ({ ...current, ...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }), })); setStatus("OCR abgeschlossen. Werte bitte kontrollieren und speichern."); } catch { setStatus("OCR konnte nicht abgeschlossen werden. Du kannst den Labeltext unten einfügen."); } finally { setOcrBusy(false); } } function parsePastedOcr() { const parsed = parseNutritionLabel(ocrText); setForm((current) => ({ ...current, ...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }), })); setStatus("Text ausgewertet. Werte bitte kontrollieren und speichern."); } return (

Health-API

Lebensmittelkatalog

Eigene Basisdaten, Supermarktprodukte, Barcode-Import und Label-OCR. Lebensmittel werden jetzt serverseitig pro Benutzer gespeichert.

setQuery(event.target.value)} placeholder="Lebensmittel, Marke, Kategorie oder Barcode suchen" className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none" />
{foods.map((food) => (

{food.name}

{food.brand || food.category} - {Number(food.servingG)} g Portion

{food.isVerified ? : null}

{Number(food.kcalPer100g)}

kcal

{Number(food.proteinPer100g)}

Protein

{Number(food.carbsPer100g)}

KH

{Number(food.fatPer100g)}

Fett

{food.source} {food.availableEverywhere ? "überall erhältlich" : "Marktzuordnung"} {food.portions?.map((portion) => ( {portion.label}: {Number(portion.amount)} {unitLabel(portion.unit)} = {Number(portion.gramsEquivalent)} g ))}

Quellen

{food.nutritionSources?.length ? (
{food.nutritionSources.map((source) => ( {source.provider}: {Number(source.kcalPer100g)} kcal/100 g ))}
) : (

Noch keine einzelnen Quellen hinterlegt.

)} {sourceFoodId === food.id ? (
setSourceQuery(event.target.value)} placeholder="Open Food Facts suchen" className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-semibold" /> {(sourceSearchQuery.data ?? []).map((source) => ( ))} {sourceSearchQuery.isFetching ?

Suche läuft...

: null}
) : null}
))} {!foods.length && !foodsQuery.isLoading ? (

Noch keine Lebensmittel gefunden.

) : null}