hellth-hub/apps/admin/app/(authed)/rezepte/page.tsx

476 lines
23 KiB
TypeScript
Raw Normal View History

"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 (
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
<div className="bg-amber-400" style={{ width: `${distribution.carbs}%` }} />
<div className="bg-blue-500" style={{ width: `${distribution.fat}%` }} />
<div className="bg-emerald-500" style={{ width: `${distribution.protein}%` }} />
</div>
);
}
function RecipeNutritionInsight({ recipe, foods }: { recipe: Recipe; foods: CatalogFood[] }) {
const totals = calcRecipe(recipe, foods);
const classification = classifyRecipe(recipe, foods);
return (
<div className="rounded-3xl bg-gray-50 p-4">
<div className="flex flex-wrap gap-2">
{classification.tags.map((tag) => (
<span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-600">
{tag}
</span>
))}
</div>
<div className="mt-4">
<MacroBar distribution={classification.distribution} />
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
<div><p className="text-lg font-black text-gray-950">{totals.kcal}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH {classification.distribution.carbs}%</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett {classification.distribution.fat}%</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Eiweiß {classification.distribution.protein}%</p></div>
</div>
</div>
<div className="mt-4 grid gap-2">
{classification.criteria.map((criterion) => (
<div key={criterion.label} className="flex items-center justify-between gap-3 text-sm font-bold">
<span className={criterion.ok ? "text-emerald-700" : "text-amber-700"}>{criterion.ok ? "OK" : "!"} {criterion.label}</span>
<span className="text-gray-400">{criterion.detail}</span>
</div>
))}
</div>
</div>
);
}
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<PortionUnit>("g");
const [portionId, setPortionId] = useState("");
const [draftIngredients, setDraftIngredients] = useState<Recipe["ingredients"]>([]);
const [status, setStatus] = useState<string | null>(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<Recipe>(() => ({
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 (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Gerichte & Rezepte</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Zutaten aus dem Lebensmittelkatalog, Alltagseinheiten, Portionen, Nährwerte und Meal-Prep-Notizen.
</p>
</header>
<section className="grid gap-4 xl:grid-cols-[1fr_0.85fr]">
<div className="space-y-4">
{recipes.map((recipe) => {
const totals = calcRecipe(recipe, foods);
return (
<article key={recipe.id} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-2xl font-black text-gray-950">{recipe.name}</p>
<p className="mt-1 text-sm font-bold text-gray-400">
{recipe.minutes} min · {recipe.portions} Portionen
{recipe.notes ? ` · ${recipe.notes}` : ""}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<div className="rounded-full bg-green-50 px-4 py-2 text-sm font-black text-green-700">{totals.kcal} kcal</div>
<div className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-4 py-2 text-sm font-black text-amber-700">
<StarIcon className="h-4 w-4" />
Bewertung folgt
</div>
</div>
</div>
<div className="mt-5 grid grid-cols-3 gap-3 text-center">
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH</p></div>
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett</p></div>
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
</div>
<div className="mt-5">
<RecipeNutritionInsight recipe={recipe} foods={foods} />
</div>
<div className="mt-5 space-y-2">
{recipe.ingredients.map((ingredient, index) => (
<div key={`${ingredient.foodId}-${index}`} className="flex justify-between gap-3 text-sm font-bold text-gray-500">
<span className="min-w-0 truncate">{foodById.get(ingredient.foodId)?.name ?? "Unbekannt"}</span>
<span>{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)</span>
</div>
))}
</div>
</article>
);
})}
{!recipes.length && !recipesQuery.isLoading ? (
<p className="rounded-[2rem] bg-white p-6 text-sm font-bold text-gray-400 shadow-sm ring-1 ring-gray-100">
Noch keine Rezepte gespeichert.
</p>
) : null}
</div>
<aside className="space-y-4">
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Vorschläge für dich</h2>
<div className="mt-4 grid grid-cols-2 gap-2">
<label className="space-y-1">
<span className="text-xs font-black uppercase text-gray-400">Mealprep Tage</span>
<select value={suggestMealPrepDays} onChange={(event) => setSuggestMealPrepDays(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold">
<option value="0">kein Fokus</option>
<option value="2">2 Tage</option>
<option value="3">3 Tage</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs font-black uppercase text-gray-400">Kochen/Tag</span>
<select value={suggestCooksPerDay} onChange={(event) => setSuggestCooksPerDay(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold">
<option value="1">1x</option>
<option value="2">2x</option>
</select>
</label>
</div>
<div className="mt-4 space-y-3">
{(recommendationsQuery.data ?? []).map((recommendation) => (
<article key={recommendation.id} className="rounded-3xl bg-gray-50 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-black text-gray-950">{recommendation.name}</p>
<p className="mt-1 text-xs font-bold text-gray-400">
{recommendation.minutes} min · Mealprep {recommendation.mealPrepDays || 1} Tag(e)
</p>
</div>
<span className="rounded-full bg-white px-3 py-1 text-xs font-black text-[#0F766E]">{recommendation.score}</span>
</div>
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
<div><p className="text-sm font-black">{recommendation.nutrition.kcal}</p><p className="text-[10px] font-bold text-gray-400">kcal</p></div>
<div><p className="text-sm font-black">{recommendation.nutrition.carbs} g</p><p className="text-[10px] font-bold text-gray-400">KH</p></div>
<div><p className="text-sm font-black">{recommendation.nutrition.fat} g</p><p className="text-[10px] font-bold text-gray-400">Fett</p></div>
<div><p className="text-sm font-black">{recommendation.nutrition.protein} g</p><p className="text-[10px] font-bold text-gray-400">Eiweiß</p></div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{[...recommendation.tags, ...recommendation.reasons].slice(0, 5).map((tag) => (
<span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-500">{tag}</span>
))}
</div>
</article>
))}
{!recommendationsQuery.isFetching && !(recommendationsQuery.data ?? []).length ? (
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">
Noch keine passenden Vorschläge. Prüfe deine Profil-Präferenzen oder lege mehr Rezepte an.
</p>
) : null}
</div>
</section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Neues Gericht</h2>
<div className="mt-4 grid gap-3">
<FormField label="Name des Gerichts">
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="z. B. Protein Bowl" className={inputClassName} />
</FormField>
<FormField label="Notiz">
<textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Reste, Meal-Prep-Hinweis oder Zubereitungsidee" className={`${inputClassName} min-h-20`} />
</FormField>
<div className="grid grid-cols-3 gap-2">
<FormField label="Minuten">
<input value={minutes} onChange={(event) => setMinutes(event.target.value)} placeholder="20" className={inputClassName} />
</FormField>
<FormField label="Portionen">
<input value={portions} onChange={(event) => setPortions(event.target.value)} placeholder="2" className={inputClassName} />
</FormField>
<FormField label="Mealprep">
<input value={mealPrepDays} onChange={(event) => setMealPrepDays(event.target.value)} placeholder="Tage" className={inputClassName} />
</FormField>
</div>
<div className="grid gap-2">
<FormField label="Lebensmittel">
<select
value={foodId}
onChange={(event) => {
const nextFood = foodById.get(event.target.value);
setFoodId(event.target.value);
setUnit(nextFood?.preferredUnit ?? "g");
setPortionId("");
}}
className={inputClassName}
>
{foods.map((food) => <option key={food.id} value={food.id}>{food.name}</option>)}
</select>
</FormField>
<div className="grid grid-cols-[1fr_112px_48px] gap-2">
<FormField label="Menge">
<input value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="100" className={inputClassName} />
</FormField>
<FormField label="Einheit">
<select value={unit} onChange={(event) => setUnit(event.target.value as PortionUnit)} className={inputClassName}>
{recipeUnits.map((recipeUnit) => <option key={recipeUnit} value={recipeUnit}>{unitLabel(recipeUnit)}</option>)}
</select>
</FormField>
<button type="button" onClick={addIngredient} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Zutat hinzufügen" title="Zutat hinzufügen">
<PlusIcon className="h-5 w-5" />
</button>
</div>
{selectedFood?.portions?.length ? (
<FormField label="Portionsgröße">
<select value={portionId} onChange={(event) => setPortionId(event.target.value)} className={inputClassName}>
<option value="">Freie Menge verwenden</option>
{selectedFood.portions.map((portion) => (
<option key={portion.id} value={portion.id}>{portion.label} ({portion.amount} {unitLabel(portion.unit)} = {portion.gramsEquivalent} g)</option>
))}
</select>
</FormField>
) : null}
</div>
<div className="rounded-3xl bg-gray-50 p-4">
{draftIngredients.length ? draftIngredients.map((ingredient, index) => (
<div key={`${ingredient.foodId}-${index}`} className="flex justify-between gap-3 py-1 text-sm font-bold text-gray-500">
<span className="min-w-0 truncate">{foodById.get(ingredient.foodId)?.name}</span>
<span>{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)</span>
</div>
)) : <p className="text-sm font-bold text-gray-400">Noch keine Zutaten.</p>}
</div>
{draftIngredients.length ? <RecipeNutritionInsight recipe={draftRecipe} foods={foods} /> : null}
<div className="grid gap-2 sm:grid-cols-2">
<button type="button" onClick={saveRecipe} disabled={saveRecipeMutation.isPending || !foods.length} className="rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50">
Privat speichern
</button>
<button type="button" onClick={submitRecipeSuggestion} disabled={submitRecipeMutation.isPending || !foods.length} className="rounded-full bg-[#0F766E] px-4 py-4 text-sm font-black text-white disabled:opacity-50">
Zur Prüfung einreichen
</button>
</div>
{status ? <p className="rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
{saveRecipeMutation.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{saveRecipeMutation.error.message}</p> : null}
{submitRecipeMutation.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{submitRecipeMutation.error.message}</p> : null}
{submissionsQuery.data?.length ? (
<div className="rounded-3xl bg-gray-50 p-4">
<h3 className="text-sm font-black text-gray-950">Meine Vorschläge</h3>
<div className="mt-3 space-y-2">
{submissionsQuery.data.slice(0, 5).map((submission) => (
<div key={submission.id} className="flex items-center justify-between gap-3 text-sm font-bold text-gray-500">
<span className="min-w-0 truncate">{submission.name}</span>
<span className={`rounded-full px-3 py-1 text-xs ${
submission.status === "accepted"
? "bg-emerald-100 text-emerald-700"
: submission.status === "rejected"
? "bg-red-100 text-red-700"
: "bg-amber-100 text-amber-700"
}`}>
{submission.status === "accepted" ? "akzeptiert" : submission.status === "rejected" ? "abgelehnt" : "in Prüfung"}
</span>
</div>
))}
</div>
</div>
) : null}
</div>
</section>
</aside>
</section>
</div>
);
}