"use client";
import { PlusIcon, StarIcon } from "@heroicons/react/24/outline";
import { useEffect, useMemo, useState } from "react";
import { FormField, inputClassName } from "@/app/_components/form-field";
import {
CatalogFood,
PortionUnit,
Recipe,
amountToGrams,
calcRecipe,
classifyRecipe,
describeIngredientAmount,
unitLabel,
} from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
const recipeUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"];
function toNumber(value: unknown) {
return Number(value) || 0;
}
function toCatalogFood(food: {
id: string;
name: string;
brand: string | null;
category: string;
kcalPer100g: unknown;
proteinPer100g: unknown;
carbsPer100g: unknown;
fatPer100g: unknown;
servingG: unknown;
preferredUnit: PortionUnit | null;
densityGPerMl: unknown | null;
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
barcode: string | null;
availableEverywhere: boolean;
portions?: Array<{
id: string;
label: string;
unit: PortionUnit;
amount: unknown;
gramsEquivalent: unknown;
}>;
}): CatalogFood {
return {
id: food.id,
name: food.name,
brand: food.brand ?? undefined,
category: food.category,
kcalPer100g: toNumber(food.kcalPer100g),
proteinPer100g: toNumber(food.proteinPer100g),
carbsPer100g: toNumber(food.carbsPer100g),
fatPer100g: toNumber(food.fatPer100g),
servingG: toNumber(food.servingG) || 100,
preferredUnit: food.preferredUnit ?? "g",
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
portions: food.portions?.map((portion) => ({
id: portion.id,
label: portion.label,
unit: portion.unit,
amount: toNumber(portion.amount),
gramsEquivalent: toNumber(portion.gramsEquivalent),
})),
source: food.source,
barcode: food.barcode ?? undefined,
availableEverywhere: food.availableEverywhere,
};
}
function recipeFromApi(recipe: {
id: string;
name: string;
portions: number;
minutes: number;
notes: string | null;
ingredients: Array<{
foodId: string;
amountG: unknown;
displayAmount: unknown | null;
displayUnit: PortionUnit | null;
portionId: string | null;
}>;
}): Recipe {
return {
id: recipe.id,
name: recipe.name,
portions: recipe.portions,
minutes: recipe.minutes,
notes: recipe.notes ?? undefined,
ingredients: recipe.ingredients.map((ingredient) => ({
foodId: ingredient.foodId,
amountG: toNumber(ingredient.amountG),
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
unit: ingredient.displayUnit ?? undefined,
portionId: ingredient.portionId ?? undefined,
})),
};
}
function MacroBar({ distribution }: { distribution: { carbs: number; fat: number; protein: number } }) {
return (
);
}
function RecipeNutritionInsight({ recipe, foods }: { recipe: Recipe; foods: CatalogFood[] }) {
const totals = calcRecipe(recipe, foods);
const classification = classifyRecipe(recipe, foods);
return (
{classification.tags.map((tag) => (
{tag}
))}
{totals.carbs} g
KH {classification.distribution.carbs}%
{totals.fat} g
Fett {classification.distribution.fat}%
{totals.protein} g
Eiweiß {classification.distribution.protein}%
{classification.criteria.map((criterion) => (
{criterion.ok ? "OK" : "!"} {criterion.label}
{criterion.detail}
))}
);
}
export default function RezeptePage() {
const utils = trpc.useUtils();
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [minutes, setMinutes] = useState("20");
const [portions, setPortions] = useState("2");
const [mealPrepDays, setMealPrepDays] = useState("1");
const [foodId, setFoodId] = useState("");
const [amount, setAmount] = useState("100");
const [unit, setUnit] = useState("g");
const [portionId, setPortionId] = useState("");
const [draftIngredients, setDraftIngredients] = useState([]);
const [status, setStatus] = useState(null);
const [suggestMealPrepDays, setSuggestMealPrepDays] = useState("0");
const [suggestCooksPerDay, setSuggestCooksPerDay] = useState("1");
const foodsQuery = trpc.health.listFoods.useQuery({ query: "" });
const recipesQuery = trpc.health.listRecipes.useQuery({ query: "" });
const recommendationsQuery = trpc.health.recipeRecommendations.useQuery({
mealPrepDays: Number(suggestMealPrepDays) || 0,
cooksPerDay: Number(suggestCooksPerDay) || 1,
limit: 8,
});
const submissionsQuery = trpc.health.myRecipeSubmissions.useQuery();
const saveRecipeMutation = trpc.health.saveRecipe.useMutation({
onSuccess: async () => {
await utils.health.listRecipes.invalidate();
await utils.health.recipeRecommendations.invalidate();
setName("");
setNotes("");
setDraftIngredients([]);
setStatus("Rezept gespeichert.");
},
});
const submitRecipeMutation = trpc.health.submitRecipeSuggestion.useMutation({
onSuccess: async (result) => {
await utils.health.myRecipeSubmissions.invalidate();
await utils.health.recipeRecommendations.invalidate();
await utils.health.clubLeaderboard.invalidate();
await utils.health.activityWeek.invalidate();
setName("");
setNotes("");
setDraftIngredients([]);
setStatus(`Rezeptvorschlag wurde zur Prüfung eingereicht. +${result.points} Hellfireclub-Punkte.`);
},
});
const foods = useMemo(() => (foodsQuery.data ?? []).map(toCatalogFood), [foodsQuery.data]);
const recipes = useMemo(() => (recipesQuery.data ?? []).map(recipeFromApi), [recipesQuery.data]);
const foodById = useMemo(() => new Map(foods.map((food) => [food.id, food])), [foods]);
const selectedFood = foodId ? foodById.get(foodId) : foods[0];
const draftRecipe = useMemo(() => ({
id: "draft",
name: name.trim() || "Entwurf",
portions: Number(portions) || 1,
minutes: Number(minutes) || 0,
notes: notes.trim() || undefined,
ingredients: draftIngredients,
}), [draftIngredients, minutes, name, notes, portions]);
useEffect(() => {
if (foodId || !foods[0]) return;
setFoodId(foods[0].id);
setUnit(foods[0].preferredUnit ?? "g");
}, [foodId, foods]);
function recipePayload() {
return {
name: name.trim(),
portions: Number(portions) || 1,
minutes: Number(minutes) || 0,
notes: notes.trim() || null,
mealPrepDays: Number(mealPrepDays) || 0,
ingredients: draftIngredients.map((ingredient, index) => ({
foodId: ingredient.foodId,
amountG: ingredient.amountG,
displayAmount: ingredient.amount ?? null,
displayUnit: ingredient.unit ?? null,
portionId: ingredient.portionId ?? null,
sortOrder: index,
})),
};
}
function addIngredient() {
if (!selectedFood) return;
const amountNumber = Number(amount.replace(",", ".")) || 1;
const amountG = amountToGrams(selectedFood, amountNumber, unit, portionId || undefined);
setDraftIngredients((current) => [
...current,
{
foodId: selectedFood.id,
amount: amountNumber,
unit,
portionId: portionId || undefined,
amountG: Math.round(amountG * 10) / 10,
},
]);
}
function saveRecipe() {
if (!name.trim() || draftIngredients.length === 0) return;
saveRecipeMutation.mutate({ ...recipePayload(), isPublic: false });
}
function submitRecipeSuggestion() {
if (!name.trim() || draftIngredients.length === 0) return;
submitRecipeMutation.mutate(recipePayload());
}
return (
Health-API
Gerichte & Rezepte
Zutaten aus dem Lebensmittelkatalog, Alltagseinheiten, Portionen, Nährwerte und Meal-Prep-Notizen.
{recipes.map((recipe) => {
const totals = calcRecipe(recipe, foods);
return (
{recipe.name}
{recipe.minutes} min · {recipe.portions} Portionen
{recipe.notes ? ` · ${recipe.notes}` : ""}
{totals.kcal} kcal
Bewertung folgt
{totals.protein} g
Protein
{recipe.ingredients.map((ingredient, index) => (
{foodById.get(ingredient.foodId)?.name ?? "Unbekannt"}
{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)
))}
);
})}
{!recipes.length && !recipesQuery.isLoading ? (
Noch keine Rezepte gespeichert.
) : null}
);
}