Health-/Ernährungslogik überarbeitet: - Fix: upsertProfile speichert fehlende Größe/Gewicht als null statt 0 (Round-Trip-Bug: gespeicherte 0 scheiterte beim erneuten Speichern an z.number().positive()). Frontend clampt zudem mealsPerDay/cooksPerDay auf die Zod-Grenzen. 3 neue API-Tests. - Ziele-Bearbeitung vom Standalone /ziele in den Einstellungen-Tab ?tab=ziele gezogen; /ziele-Seite + Sidebar-Eintrag entfernt. Liest/ schreibt jetzt aus der DB (vorher las der Tab veraltetes localStorage). - Wochenziel wird automatisch als Tagesziel × 7 berechnet (Feld entfällt). - Sportbonus klar dargestellt: Tagesansicht addiert den heutigen Sport aufs Budget (nur wenn sportAddsCalories aktiv) mit Bolt-Badge; Wochen- Box mit Icon und Info "ganze Woche". Dashboard invalidiert alle statsSummary-Varianten. - Belohnende Banner bei Sport heute und bei Kaloriendefizit. - Doku: rules.md-Verweis auf docs/projektreferenz.md korrigiert. - Neuer Skill: ernaehrungswissenschaftler (fachliche Abnahme der Health-Logik). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
819 lines
36 KiB
TypeScript
819 lines
36 KiB
TypeScript
"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>
|
|
);
|
|
}
|