hellth-hub/apps/admin/app/(authed)/page.tsx
Sebastian Mayer 7488916a9e
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run
Fix: ernährungsfachliche Auflagen aus der Abnahme umgesetzt
Nachbesserungen aus der fachlichen Begutachtung (Skill
ernaehrungswissenschaftler):

- Kalorien-Untergrenze von 800 auf 1.200 kcal angehoben (goalsInput und
  upsertWeeklyCaloriePlan, neue Konstante MIN_CALORIE_GOAL). Im Ziele-Tab
  Sicherheitswarnung bei einem Ziel unter 1.500 kcal.
- Defizit-Banner wird nur noch bei moderatem Defizit (10-25 % unter dem
  Basis-Tagesziel) gefeiert und gegen baseDailyGoal statt inkl. Sportbonus
  gerechnet. Bei einem Defizit über 25 % erscheint stattdessen ein sanfter
  "iss genug"-Hinweis statt Lob.
- Wochenbudget-Verteilung: der am 1.200-Floor gekappte Rest wird iterativ
  auf die übrigen Tage weiterverteilt, sodass das Wochenbudget erhalten
  bleibt, solange es geht. Neue Funktion weeklyExtrasOverflow meldet den
  unvermeidbaren Überschuss; die Wochenbudget-Seite weist ihn ehrlich aus
  statt pauschal "bleibt gleich" zu versprechen.
- Dashboard-Makros: Kohlenhydrate und Fett als geschätzt (~) gekennzeichnet
  inkl. erklärendem Hinweis; Protein bleibt real getrackt.
- 2 neue API-Tests (iterative Floor-Verteilung, weeklyExtrasOverflow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:41:41 +02:00

848 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
BeakerIcon,
BoltIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
EllipsisHorizontalIcon,
FireIcon,
PlusIcon,
ScaleIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
type MealType = "Frühstück" | "Mittagessen" | "Abendessen" | "Snack";
type ApiMealType = "breakfast" | "lunch" | "dinner" | "snack";
type FoodEntry = {
id: string;
mealType: MealType;
name: string;
amount: string;
calories: number;
protein: number;
};
type DrinkEntry = {
id: string;
amountMl: number;
};
type PlannedMeal = {
id: string;
mealType: MealType;
name: string;
note?: string;
portions?: number;
plannedCalories?: number;
};
type DayData = {
foods: FoodEntry[];
drinks: DrinkEntry[];
weightKg?: number;
plannedMeals: PlannedMeal[];
};
const mealTypes: MealType[] = ["Frühstück", "Mittagessen", "Abendessen", "Snack"];
const mealTypeToApi: Record<MealType, ApiMealType> = {
Frühstück: "breakfast",
Mittagessen: "lunch",
Abendessen: "dinner",
Snack: "snack",
};
const mealTypeFromApi: Record<ApiMealType, MealType> = {
breakfast: "Frühstück",
lunch: "Mittagessen",
dinner: "Abendessen",
snack: "Snack",
};
const fallbackGoals = {
drinkMl: 2500,
calories: 2000,
carbs: 200,
fat: 67,
protein: 150,
fastingHours: 12,
sportAddsCalories: true,
};
function toDateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
function parseDateKey(key: string) {
return new Date(`${key}T12:00:00`);
}
function addDays(key: string, delta: number) {
const date = parseDateKey(key);
date.setDate(date.getDate() + delta);
return toDateKey(date);
}
function emptyDay(): DayData {
return {
foods: [],
drinks: [],
plannedMeals: [],
};
}
function formatDate(key: string) {
return parseDateKey(key).toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
});
}
function progress(value: number, goal: number) {
if (goal <= 0) return 0;
return Math.min(100, Math.round((value / goal) * 100));
}
function foodSwatch(name: string) {
const colors = [
"from-amber-100 to-orange-200 text-orange-700",
"from-emerald-100 to-lime-200 text-emerald-700",
"from-sky-100 to-blue-200 text-blue-700",
"from-rose-100 to-pink-200 text-rose-700",
];
const index = name.length % colors.length;
return colors[index] ?? colors[0];
}
function RecipeAvatar({ name }: { name: string }) {
const initials = name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
return (
<div className={`flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${foodSwatch(name)} text-lg font-black shadow-inner`}>
{initials || "HH"}
</div>
);
}
function WeekStrip({
selectedDate,
onSelect,
}: {
selectedDate: string;
onSelect: (date: string) => void;
}) {
const days = useMemo(() => {
const selected = parseDateKey(selectedDate);
const day = selected.getDay() === 0 ? 7 : selected.getDay();
const monday = new Date(selected);
monday.setDate(selected.getDate() - day + 1);
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(monday);
date.setDate(monday.getDate() + index);
return toDateKey(date);
});
}, [selectedDate]);
return (
<div className="grid grid-cols-7 gap-2">
{days.map((dateKey) => {
const date = parseDateKey(dateKey);
const active = dateKey === selectedDate;
return (
<button
type="button"
key={dateKey}
onClick={() => onSelect(dateKey)}
className={`flex min-h-16 flex-col items-center justify-center rounded-2xl text-sm transition ${
active
? "bg-white text-gray-950 shadow-sm ring-1 ring-gray-100"
: "text-gray-400 hover:bg-white/60"
}`}
>
<span className="font-semibold uppercase">{date.toLocaleDateString("de-DE", { weekday: "narrow" })}</span>
<span className="text-xl font-black">{date.getDate()}</span>
</button>
);
})}
</div>
);
}
function MacroBar({ carbs, fat, protein }: { carbs: number; fat: number; protein: number }) {
const total = Math.max(1, carbs + fat + protein);
return (
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
<div className="bg-amber-400" style={{ width: `${(carbs / total) * 100}%` }} />
<div className="bg-blue-500" style={{ width: `${(fat / total) * 100}%` }} />
<div className="bg-green-500" style={{ width: `${(protein / total) * 100}%` }} />
</div>
);
}
function WaterGlasses({
amount,
goal,
onAdd,
locked,
}: {
amount: number;
goal: number;
onAdd: () => void;
locked: boolean;
}) {
const glasses = Math.max(6, Math.ceil(goal / 250));
const filled = Math.min(glasses, Math.floor(amount / 250));
return (
<div className="mt-6 grid grid-cols-6 gap-3 sm:grid-cols-10">
{Array.from({ length: glasses }, (_, index) => {
const isFilled = index < filled;
const isNext = index === filled && !locked;
return (
<button
type="button"
key={index}
disabled={locked || (!isNext && !isFilled)}
onClick={isNext ? onAdd : undefined}
className={`relative h-16 rounded-b-2xl rounded-t-md border border-blue-50 ${
isFilled ? "bg-blue-500" : "bg-blue-50"
} ${isNext ? "text-gray-950" : "text-transparent"} disabled:cursor-default`}
aria-label={isNext ? "250 ml Wasser hinzufügen" : undefined}
title={isNext ? "250 ml Wasser hinzufügen" : undefined}
>
<span className="absolute left-3 top-2 h-9 w-2 rounded-full bg-white/35" />
{isNext ? <PlusIcon className="absolute inset-0 m-auto h-6 w-6" /> : null}
</button>
);
})}
</div>
);
}
function PlanList({
meals,
onRemove,
canRemove,
}: {
meals: PlannedMeal[];
onRemove: (id: string) => void;
canRemove: boolean;
}) {
if (meals.length === 0) {
return (
<div className="mt-5 rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">
Noch keine Mahlzeit geplant.
</div>
);
}
return (
<div className="mt-5 space-y-4">
{meals.map((meal) => (
<div key={meal.id} className="flex items-center gap-4 border-b border-gray-100 pb-4 last:border-0 last:pb-0">
<RecipeAvatar name={meal.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-lg font-black text-gray-950">{meal.name}</p>
<p className="text-sm font-semibold text-gray-400">
{meal.mealType}
{meal.portions ? ` - ${meal.portions.toLocaleString("de-DE")} Portion` : ""}
{meal.plannedCalories ? ` - ${meal.plannedCalories.toLocaleString("de-DE")} kcal` : ""}
{meal.note ? ` - ${meal.note}` : ""}
</p>
</div>
{canRemove ? (
<button
type="button"
onClick={() => onRemove(meal.id)}
className="rounded-full bg-gray-100 p-2 text-gray-500 hover:text-red-600"
aria-label="Planung löschen"
title="Planung löschen"
>
<TrashIcon className="h-4 w-4" />
</button>
) : null}
</div>
))}
</div>
);
}
export default function DashboardPage() {
const utils = trpc.useUtils();
const todayKey = toDateKey(new Date());
const [selectedDate, setSelectedDate] = useState(todayKey);
const [quickMode, setQuickMode] = useState<"meal" | "plan" | "drink" | "weight">("meal");
const [foodForm, setFoodForm] = useState({
mealType: "Frühstück" as MealType,
name: "",
amount: "",
calories: "",
protein: "",
});
const [weightForm, setWeightForm] = useState("");
const [planForm, setPlanForm] = useState({
mealType: "Mittagessen" as MealType,
name: "",
note: "",
portions: "1",
plannedCalories: "",
});
const overviewQuery = trpc.health.overview.useQuery({ date: selectedDate });
const statsSummaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: todayKey });
const weeklyBudget = statsSummaryQuery.data?.week ?? null;
const coachRecommendations = overviewQuery.data?.coachRecommendations ?? [];
function invalidateDay() {
utils.health.overview.invalidate({ date: selectedDate });
// Alle statsSummary-Varianten invalidieren, damit Wochenbudget-Seite und
// Statistiken konsistent bleiben (unterschiedliche referenceDate-Caches).
utils.health.statsSummary.invalidate();
}
const addMealMutation = trpc.health.addMealLog.useMutation({ onSuccess: invalidateDay });
const addDrinkMutation = trpc.health.addDrinkLog.useMutation({ onSuccess: invalidateDay });
const saveWeightMutation = trpc.health.upsertWeightLog.useMutation({
onSuccess: () => {
invalidateDay();
utils.health.weightHistory.invalidate();
},
});
const saveMealPlanMutation = trpc.health.upsertMealPlan.useMutation({ onSuccess: invalidateDay });
const deleteMealMutation = trpc.health.deleteMealLog.useMutation({ onSuccess: invalidateDay });
const deletePlanMutation = trpc.health.deleteMealPlan.useMutation({ onSuccess: invalidateDay });
const goals = overviewQuery.data?.goals
? {
drinkMl: overviewQuery.data.goals.drinkMl,
calories: overviewQuery.data.goals.calories,
carbs: Number(overviewQuery.data.goals.carbsG) || fallbackGoals.carbs,
fat: Number(overviewQuery.data.goals.fatG) || fallbackGoals.fat,
protein: Number(overviewQuery.data.goals.proteinG) || fallbackGoals.protein,
fastingHours: overviewQuery.data.goals.fastingHours,
sportAddsCalories: overviewQuery.data.goals.sportAddsCalories ?? true,
}
: fallbackGoals;
const day: DayData = overviewQuery.data?.day
? {
foods: overviewQuery.data.day.mealLogs.map((meal) => ({
id: meal.id,
mealType: mealTypeFromApi[meal.mealType as ApiMealType] ?? "Snack",
name: meal.name,
amount: meal.amountLabel ?? "1 Portion",
calories: meal.calories,
protein: Number(meal.proteinG) || 0,
})),
drinks: overviewQuery.data.day.drinkLogs.map((drink) => ({
id: drink.id,
amountMl: drink.amountMl,
})),
weightKg: overviewQuery.data.day.weightLog ? Number(overviewQuery.data.day.weightLog.weightKg) : undefined,
plannedMeals: overviewQuery.data.day.mealPlans.map((meal) => ({
id: meal.id,
mealType: mealTypeFromApi[meal.mealType as ApiMealType] ?? "Snack",
name: meal.name,
note: meal.note ?? undefined,
portions: Number(meal.portions) || 1,
plannedCalories: Number(meal.plannedCalories) || 0,
})),
}
: emptyDay();
const isFuture = selectedDate > todayKey;
const isToday = selectedDate === todayKey;
const isEditable = selectedDate <= todayKey;
const totals = useMemo(() => {
const calories = day.foods.reduce((sum, food) => sum + (Number(food.calories) || 0), 0);
const protein = day.foods.reduce((sum, food) => sum + (Number(food.protein) || 0), 0);
const drinkMl = day.drinks.reduce((sum, drink) => sum + (Number(drink.amountMl) || 0), 0);
const carbs = Math.round((calories * 0.45) / 4);
const fat = Math.round((calories * 0.3) / 9);
return { calories, protein, drinkMl, carbs, fat };
}, [day.drinks, day.foods]);
// Tageswerte aus dem Wochenplan (enthaelt die per Wochenbudget verteilten Ziele
// und einen evtl. eingeplanten Mehrbedarf fuer diesen Tag).
const weekDay = weeklyBudget?.days.find((entry) => entry.date === selectedDate);
const dayActivityCalories = weekDay?.activityCalories ?? 0;
const dailySportBonus = goals.sportAddsCalories ? dayActivityCalories : 0;
// Basis-Tagesziel ist der verteilte Plan-Wert dieses Tages (z. B. 1.740 nach
// Umverteilung oder 2.800 an einem Blocker-Tag), sonst das allgemeine Tagesziel.
const baseDailyGoal = weekDay?.plannedCalories ?? goals.calories;
const dayExtraCalories = weekDay?.extraCalories ?? 0;
const dailyCalorieGoal = baseDailyGoal + dailySportBonus;
const remainingCalories = Math.max(0, dailyCalorieGoal - totals.calories);
const nutritionScore = Math.min(100, Math.round((progress(totals.protein, goals.protein) + progress(totals.drinkMl, goals.drinkMl)) / 2));
// Belohnende Hinweise: nur fuer vergangene/heutige Tage und nur wenn schon
// etwas getrackt wurde, damit "Defizit" am leeren Morgen nicht falsch jubelt.
// Defizit gegen das Basis-Tagesziel rechnen (ohne Sportbonus), damit der Bonus
// den "Erfolg" nicht kuenstlich aufblaeht.
const calorieDeficit = baseDailyGoal - totals.calories;
const deficitRatio = baseDailyGoal > 0 ? calorieDeficit / baseDailyGoal : 0;
const hasTrackedFood = !isFuture && totals.calories > 0;
// Nur ein moderates Defizit (10-25 % unter Ziel) wird gefeiert. Daraus folgt
// gesundes, nicht restriktives Verhalten.
const showDeficitPraise = hasTrackedFood && deficitRatio >= 0.1 && deficitRatio <= 0.25;
// Zu starkes Defizit (> 25 % unter Ziel) wird NICHT bejubelt, sondern sanft gewarnt.
const showUnderEatingWarning = hasTrackedFood && deficitRatio > 0.25;
const showSportPraise = !isFuture && dailySportBonus > 0;
function addFoodEntry() {
if (!isEditable || !foodForm.name.trim()) return;
addMealMutation.mutate({
date: selectedDate,
mealType: mealTypeToApi[foodForm.mealType],
name: foodForm.name.trim(),
amountLabel: foodForm.amount.trim() || "1 Portion",
calories: Number(foodForm.calories) || 0,
proteinG: Number(foodForm.protein) || 0,
carbsG: 0,
fatG: 0,
});
setFoodForm((current) => ({ ...current, name: "", amount: "", calories: "", protein: "" }));
}
function addDrinkEntry() {
if (!isEditable) return;
addDrinkMutation.mutate({ date: selectedDate, amountMl: 250 });
}
function saveWeight() {
if (!isEditable) return;
const weightKg = Number(weightForm);
if (!weightKg || weightKg <= 0) return;
saveWeightMutation.mutate({ date: selectedDate, weightKg, note: null });
setWeightForm("");
}
function addPlannedMeal() {
if (!planForm.name.trim()) return;
saveMealPlanMutation.mutate({
date: selectedDate,
mealType: mealTypeToApi[planForm.mealType],
name: planForm.name.trim(),
portions: Number(planForm.portions) || 1,
plannedCalories: Number(planForm.plannedCalories) || 0,
note: planForm.note.trim() || null,
recipeId: null,
});
setPlanForm((current) => ({ ...current, name: "", note: "", portions: "1", plannedCalories: "" }));
}
function removeEntry(kind: "foods" | "plannedMeals", id: string) {
if (kind === "foods") deleteMealMutation.mutate({ id });
if (kind === "plannedMeals") deletePlanMutation.mutate({ id });
}
return (
<div className="mx-auto max-w-5xl pb-8">
<div className="relative -mx-4 -mt-4 overflow-hidden bg-[#45A85A] px-4 pb-8 pt-7 text-white sm:-mx-6 sm:px-6 lg:mx-0 lg:mt-0 lg:rounded-[2rem]">
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-pink-200/70" />
<div className="absolute -left-16 top-24 h-44 w-44 rounded-full bg-teal-100/70" />
<div className="relative flex items-center justify-between">
<button
type="button"
onClick={() => setSelectedDate((current) => addDays(current, -1))}
className="rounded-full bg-white/15 p-3 backdrop-blur"
aria-label="Einen Tag zurück"
title="Einen Tag zurück"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
<div className="text-center">
<p className="text-sm font-bold uppercase opacity-80">{isToday ? "Heute" : isFuture ? "Planung" : "Rückblick"}</p>
<h1 className="text-4xl font-black tracking-normal">{formatDate(selectedDate).split(",")[0]}</h1>
</div>
<button
type="button"
onClick={() => setSelectedDate((current) => addDays(current, 1))}
className="rounded-full bg-white/15 p-3 backdrop-blur"
aria-label="Einen Tag vor"
title="Einen Tag vor"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</div>
<div className="relative mt-8 flex items-end justify-between">
<div>
<p className="text-lg font-bold opacity-90">Hellth Hub</p>
<p className="mt-1 max-w-xs text-sm font-medium opacity-75">
{isFuture
? "Zukunftstage sind für Tracking gesperrt, aber für den Essensplan frei."
: "Tracking und Tagesziele für deinen Alltag."}
</p>
</div>
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/20 text-5xl backdrop-blur">HH</div>
</div>
<div className="relative mt-8">
<WeekStrip selectedDate={selectedDate} onSelect={setSelectedDate} />
</div>
</div>
<div className="-mt-3 space-y-4 lg:-mt-6">
{weeklyBudget ? (
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-black text-gray-400">Diese Woche</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">Flexibles Kalorienbudget ohne Tagesstress.</p>
</div>
<a href="/wochenbudget" className="rounded-full bg-gray-950 px-5 py-3 text-sm font-black text-white">Planen</a>
</div>
<div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{weeklyBudget.baseBudget.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-gray-400">Ziel kcal</p></div>
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{weeklyBudget.eatenCalories.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-gray-400">gegessen/geplant</p></div>
<div className="rounded-3xl bg-emerald-50 p-4">
<div className="flex items-center gap-1.5 text-emerald-700">
<BoltIcon className="h-5 w-5" />
<p className="text-2xl font-black">+{weeklyBudget.activityCalories.toLocaleString("de-DE")}</p>
</div>
<p className="text-xs font-bold text-emerald-700">Sportbonus (ganze Woche)</p>
</div>
<div className="rounded-3xl bg-blue-50 p-4"><p className="text-2xl font-black text-blue-700">{weeklyBudget.availableCalories.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-blue-700">verfügbar</p></div>
</div>
{weeklyBudget.activityCalories > 0 ? (
<p className="mt-4 flex items-center gap-1.5 text-xs font-semibold text-gray-400">
<BoltIcon className="h-4 w-4 text-emerald-600" />
Der Sportbonus summiert alle Aktivitätskalorien dieser Woche und erhöht dein verfügbares Budget.
</p>
) : null}
</section>
) : null}
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
{showSportPraise || showDeficitPraise || showUnderEatingWarning ? (
<div className="mb-5 space-y-2">
{showSportPraise ? (
<div className="flex items-center gap-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">
<span className="text-xl" aria-hidden>⚡</span>
<span>
Stark! Du hast dir heute {dailySportBonus.toLocaleString("de-DE")} kcal
Sportbonus erkämpft. Weiter so!
</span>
</div>
) : null}
{showDeficitPraise ? (
<div className="flex items-center gap-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">
<span className="text-xl" aria-hidden>🎉</span>
<span>
{calorieDeficit.toLocaleString("de-DE")} kcal unter dem Ziel — ein gesundes,
moderates Defizit. Gute Arbeit für deine Ziele!
</span>
</div>
) : null}
{showUnderEatingWarning ? (
<div className="flex items-center gap-3 rounded-2xl bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800">
<span className="text-xl" aria-hidden>💛</span>
<span>
Du liegst {calorieDeficit.toLocaleString("de-DE")} kcal unter deinem Ziel –
das ist ein recht großes Defizit. Achte darauf, genug zu essen; dauerhaft zu
wenig schadet mehr, als es hilft.
</span>
</div>
) : null}
</div>
) : null}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-gray-400">
<FireIcon className="h-5 w-5 text-orange-500" />
<p className="text-lg font-black">Kalorien übrig</p>
</div>
<p className="mt-5 text-5xl font-black text-gray-950 dark:text-neutral-100">
{remainingCalories.toLocaleString("de-DE")} <span className="text-3xl text-gray-300">kcal</span>
</p>
{dailySportBonus > 0 ? (
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1.5 text-sm font-bold text-emerald-700">
<BoltIcon className="h-4 w-4" />
+{dailySportBonus.toLocaleString("de-DE")} kcal durch Sport heute
</div>
) : null}
</div>
<button
type="button"
onClick={() => setQuickMode(isFuture ? "plan" : "meal")}
className="grid h-20 w-20 place-items-center rounded-full bg-gray-50 text-gray-950 ring-[10px] ring-gray-100"
aria-label="Eintrag hinzufügen"
title="Eintrag hinzufügen"
>
<PlusIcon className="h-8 w-8" />
</button>
</div>
<div className="mt-8">
<MacroBar carbs={goals.carbs} fat={goals.fat} protein={goals.protein} />
<div className="mt-5 grid grid-cols-3 gap-3">
<div>
<p className="text-sm font-bold text-gray-400">Kohlenhydr.</p>
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">~{totals.carbs}<span className="text-gray-300">/{goals.carbs} g</span></p>
</div>
<div>
<p className="text-sm font-bold text-gray-400">Fett</p>
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">~{totals.fat}<span className="text-gray-300">/{goals.fat} g</span></p>
</div>
<div>
<p className="text-sm font-bold text-gray-400">Proteine</p>
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{Math.round(totals.protein)}<span className="text-gray-300">/{goals.protein} g</span></p>
</div>
</div>
<p className="mt-2 text-xs font-semibold text-gray-400">
Kohlenhydrate und Fett sind aus den Kalorien geschätzt (~). Protein wird aus deinen Einträgen berechnet.
</p>
</div>
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
<span>
Ziel: {baseDailyGoal.toLocaleString("de-DE")} kcal
{dailySportBonus > 0
? ` + ${dailySportBonus.toLocaleString("de-DE")} Sport = ${dailyCalorieGoal.toLocaleString("de-DE")} kcal`
: ""}
{dayExtraCalories > 0 ? " · höherer Tag eingeplant" : ""}
{baseDailyGoal < goals.calories && dayExtraCalories === 0
? " · Ausgleich für höheren Tag"
: ""}
</span>
<EllipsisHorizontalIcon className="h-7 w-7" />
</div>
</section>
{coachRecommendations.length > 0 ? (
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-black text-gray-400">Coach</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">Persönliche Vorschläge aus deinem Fragebogen.</p>
</div>
<a href="/onboarding" className="rounded-full bg-gray-100 px-4 py-2 text-sm font-black text-gray-950">
Anpassen
</a>
</div>
<div className="mt-5 grid gap-3 lg:grid-cols-3">
{coachRecommendations.slice(0, 3).map((item) => (
<article
key={item.id}
className={`rounded-3xl p-4 ${
item.priority === "high"
? "bg-emerald-50 text-emerald-950"
: item.priority === "medium"
? "bg-blue-50 text-blue-950"
: "bg-gray-50 text-gray-950"
}`}
>
<p className="font-black">{item.title}</p>
<p className="mt-2 text-sm font-semibold opacity-75">{item.body}</p>
</article>
))}
</div>
</section>
) : null}
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-center justify-between">
<h2 className="text-xl font-black text-gray-400">Ernährungsplan</h2>
<a href="/planung" className="rounded-full border border-gray-200 px-5 py-2 text-sm font-black text-gray-900 hover:bg-gray-50 dark:text-neutral-100">
Alle ansehen
</a>
</div>
<PlanList
meals={day.plannedMeals.slice(0, 5)}
onRemove={(id) => removeEntry("plannedMeals", id)}
canRemove={day.plannedMeals.length > 0}
/>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-gray-400">
<BeakerIcon className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-black">Wasser</h2>
</div>
<p className="mt-5 text-5xl font-black text-gray-950 dark:text-neutral-100">
{totals.drinkMl} <span className="text-3xl text-gray-300">ml</span>
</p>
</div>
<button
type="button"
onClick={addDrinkEntry}
disabled={!isEditable || addDrinkMutation.isPending}
className="grid h-20 w-20 place-items-center rounded-full bg-gray-50 text-gray-950 ring-[10px] ring-gray-100 disabled:opacity-40"
aria-label="250 ml Wasser hinzufügen"
title="250 ml Wasser hinzufügen"
>
<PlusIcon className="h-8 w-8" />
</button>
</div>
<WaterGlasses amount={totals.drinkMl} goal={goals.drinkMl} onAdd={addDrinkEntry} locked={!isEditable || addDrinkMutation.isPending} />
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
<span>Tagesziel: {goals.drinkMl} ml</span>
<EllipsisHorizontalIcon className="h-7 w-7" />
</div>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-gray-400">
<ClockIcon className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-black">Fasten</h2>
</div>
<p className="mt-5 text-4xl font-black text-gray-200">Bereit zum Starten?</p>
<p className="mt-5 text-sm font-bold text-gray-400">Ziel: {goals.fastingHours} h</p>
</div>
<button type="button" className="grid h-20 w-20 place-items-center rounded-full bg-gray-100 text-sm font-black text-gray-950">
START
</button>
</div>
</div>
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-gray-400">
<ScaleIcon className="h-5 w-5 text-rose-500" />
<h2 className="text-xl font-black">Gewicht</h2>
</div>
<p className="mt-5 text-4xl font-black text-gray-950 dark:text-neutral-100">
{day.weightKg ? `${day.weightKg} kg` : "-"}
</p>
<p className="mt-2 text-sm font-bold text-gray-400">{isEditable ? "Ein Tageswert reicht." : "Für Zukunftstage gesperrt."}</p>
</div>
<div className="grid h-20 w-20 place-items-center rounded-full bg-green-50 text-2xl font-black text-green-600">
{nutritionScore}
</div>
</div>
{isEditable ? (
<div className="mt-6 grid grid-cols-[1fr_96px] gap-3">
<input
type="number"
step="0.1"
value={weightForm}
onChange={(event) => setWeightForm(event.target.value)}
placeholder={day.weightKg ? String(day.weightKg) : "kg"}
className="rounded-full border border-gray-200 px-4 py-3 text-sm"
/>
<button type="button" onClick={saveWeight} disabled={saveWeightMutation.isPending} className="rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
OK
</button>
</div>
) : null}
</div>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
<div className="flex items-center justify-between">
<h2 className="text-xl font-black text-gray-400">{isFuture ? "Gericht planen" : "Schnellerfassung"}</h2>
{isFuture ? <span className="rounded-full bg-amber-50 px-3 py-1 text-xs font-bold text-amber-700">Tracking gesperrt</span> : null}
</div>
<div className="mt-5 grid grid-cols-2 gap-2 rounded-full bg-gray-100 p-1 sm:grid-cols-4">
{(["meal", "plan", "drink", "weight"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setQuickMode(mode)}
disabled={isFuture && mode !== "plan"}
className={`rounded-full px-4 py-2 text-sm font-black disabled:opacity-40 ${
quickMode === mode ? "bg-white text-gray-950 shadow-sm" : "text-gray-400"
}`}
>
{mode === "meal" ? "Essen" : mode === "plan" ? "Planen" : mode === "drink" ? "Wasser" : "Gewicht"}
</button>
))}
</div>
{quickMode === "meal" ? (
<div className="mt-5 grid gap-3 lg:grid-cols-12">
<select value={foodForm.mealType} onChange={(event) => setFoodForm((current) => ({ ...current, mealType: event.target.value as MealType }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2">
{mealTypes.map((mealType) => <option key={mealType}>{mealType}</option>)}
</select>
<input value={foodForm.name} onChange={(event) => setFoodForm((current) => ({ ...current, name: event.target.value }))} placeholder="z. B. Skyr mit Haferflocken" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-4" />
<input value={foodForm.amount} onChange={(event) => setFoodForm((current) => ({ ...current, amount: event.target.value }))} placeholder="Menge" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2" />
<input type="number" value={foodForm.calories} onChange={(event) => setFoodForm((current) => ({ ...current, calories: event.target.value }))} placeholder="kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
<input type="number" value={foodForm.protein} onChange={(event) => setFoodForm((current) => ({ ...current, protein: event.target.value }))} placeholder="Protein" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
<button type="button" onClick={addFoodEntry} disabled={!isEditable || addMealMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40 lg:col-span-2">
Protokollieren
</button>
</div>
) : null}
{quickMode === "plan" ? (
<div className="mt-5 grid gap-3 lg:grid-cols-12">
<select value={planForm.mealType} onChange={(event) => setPlanForm((current) => ({ ...current, mealType: event.target.value as MealType }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2">
{mealTypes.map((mealType) => <option key={mealType}>{mealType}</option>)}
</select>
<input value={planForm.name} onChange={(event) => setPlanForm((current) => ({ ...current, name: event.target.value }))} placeholder="Geplantes Gericht" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-4" />
<input value={planForm.note} onChange={(event) => setPlanForm((current) => ({ ...current, note: event.target.value }))} placeholder="Notiz / Reste" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-3" />
<input type="number" min="0.25" step="0.25" value={planForm.portions} onChange={(event) => setPlanForm((current) => ({ ...current, portions: event.target.value }))} placeholder="Portionen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
<input type="number" value={planForm.plannedCalories} onChange={(event) => setPlanForm((current) => ({ ...current, plannedCalories: event.target.value }))} placeholder="kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
<button type="button" onClick={addPlannedMeal} disabled={saveMealPlanMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40 lg:col-span-1">
Planen
</button>
</div>
) : null}
{quickMode === "drink" ? (
<button type="button" onClick={addDrinkEntry} disabled={!isEditable || addDrinkMutation.isPending} className="mt-5 w-full rounded-2xl bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-40">
250 ml Wasser eintragen
</button>
) : null}
{quickMode === "weight" ? (
<div className="mt-5 grid grid-cols-[1fr_120px] gap-3">
<input type="number" step="0.1" value={weightForm} onChange={(event) => setWeightForm(event.target.value)} placeholder="kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<button type="button" onClick={saveWeight} disabled={!isEditable || saveWeightMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
Speichern
</button>
</div>
) : null}
{day.foods.length > 0 ? (
<div className="mt-6 space-y-3">
{day.foods.map((food) => (
<div key={food.id} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
<RecipeAvatar name={food.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-black text-gray-950">{food.name}</p>
<p className="text-xs font-bold text-gray-400">{food.mealType} - {food.amount} - {food.calories ?? 0} kcal</p>
</div>
{isEditable ? (
<button type="button" onClick={() => removeEntry("foods", food.id)} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Eintrag löschen" title="Eintrag löschen">
<TrashIcon className="h-4 w-4" />
</button>
) : null}
</div>
))}
</div>
) : null}
</section>
</div>
<button
type="button"
onClick={() => setQuickMode(isFuture ? "plan" : "meal")}
className="fixed bottom-24 right-5 z-40 grid h-20 w-20 place-items-center rounded-full bg-gray-950 text-white shadow-2xl lg:hidden"
aria-label="Neuen Eintrag starten"
title="Neuen Eintrag starten"
>
<PlusIcon className="h-9 w-9" />
</button>
</div>
);
}