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

820 lines
36 KiB
TypeScript
Raw Normal View History

"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]);
// Sportbonus des AUSGEWAEHLTEN Tages (nicht der ganzen Woche). Wird nur dann
// aufs Tagesziel addiert, wenn der Nutzer das in den Zielen aktiviert hat.
const dayActivityCalories =
weeklyBudget?.days.find((entry) => entry.date === selectedDate)?.activityCalories ?? 0;
const dailySportBonus = goals.sportAddsCalories ? dayActivityCalories : 0;
const dailyCalorieGoal = goals.calories + 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.
const calorieDeficit = dailyCalorieGoal - totals.calories;
const showDeficitPraise = !isFuture && totals.calories > 0 && calorieDeficit > 0;
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 ? (
<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-amber-50 px-4 py-3 text-sm font-bold text-amber-800">
<span className="text-xl" aria-hidden>🎉</span>
<span>
{calorieDeficit.toLocaleString("de-DE")} kcal im Plus — du liegst im
Defizit. Gute Arbeit für deine Ziele!
</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>
</div>
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
<span>
Ziel: {goals.calories.toLocaleString("de-DE")} kcal
{dailySportBonus > 0
? ` + ${dailySportBonus.toLocaleString("de-DE")} Sport = ${dailyCalorieGoal.toLocaleString("de-DE")} kcal`
: ""}
</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>
);
}