hellth-hub/apps/admin/app/(authed)/planung/page.tsx
Sebastian Mayer 2436dd9cac Initial commit: Hellth Hub monorepo
Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-,
Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb.

Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) -
Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) -
PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin.

Module:
- Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery
- Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen
- Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts
- Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen
- Essensplanung und Wochenbudget mit Sportbonus
- Einkaufslisten mit Maerkten und Laufweg-Sortierung
- Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung
- Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:15:16 +02:00

324 lines
12 KiB
TypeScript

"use client";
import {
ArrowLeftIcon,
Cog6ToothIcon,
PlusIcon,
ShoppingCartIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import {
CatalogFood,
MealType,
PortionUnit,
Recipe,
calcRecipe,
todayKey,
weekDates,
} from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
type ApiMealType = "breakfast" | "lunch" | "dinner" | "snack";
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",
};
function toNumber(value: unknown) {
return Number(value) || 0;
}
function parseDateKey(key: string) {
return new Date(`${key}T12:00:00`);
}
function apiFoodToCatalog(food: {
id: string;
name: string;
brand: string | null;
category: string;
kcalPer100g: unknown;
proteinPer100g: unknown;
carbsPer100g: unknown;
fatPer100g: unknown;
servingG: unknown;
preferredUnit: PortionUnit | null;
densityGPerMl: unknown | null;
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
barcode: string | null;
availableEverywhere: boolean;
}): CatalogFood {
return {
id: food.id,
name: food.name,
brand: food.brand ?? undefined,
category: food.category,
kcalPer100g: toNumber(food.kcalPer100g),
proteinPer100g: toNumber(food.proteinPer100g),
carbsPer100g: toNumber(food.carbsPer100g),
fatPer100g: toNumber(food.fatPer100g),
servingG: toNumber(food.servingG) || 100,
preferredUnit: food.preferredUnit ?? "g",
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
source: food.source,
barcode: food.barcode ?? undefined,
availableEverywhere: food.availableEverywhere,
};
}
function apiRecipeToRecipe(recipe: {
id: string;
name: string;
portions: number;
minutes: number;
notes: string | null;
ingredients: Array<{
foodId: string;
amountG: unknown;
displayAmount: unknown | null;
displayUnit: PortionUnit | null;
portionId: string | null;
}>;
}): Recipe {
return {
id: recipe.id,
name: recipe.name,
portions: recipe.portions,
minutes: recipe.minutes,
notes: recipe.notes ?? undefined,
ingredients: recipe.ingredients.map((ingredient) => ({
foodId: ingredient.foodId,
amountG: toNumber(ingredient.amountG),
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
unit: ingredient.displayUnit ?? undefined,
portionId: ingredient.portionId ?? undefined,
})),
};
}
function 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",
];
return colors[name.length % colors.length] ?? 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-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${foodSwatch(name)} text-base font-black shadow-inner`}>
{initials}
</div>
);
}
export default function PlanungPage() {
const utils = trpc.useUtils();
const [selectedDate, setSelectedDate] = useState(todayKey());
const [recipeId, setRecipeId] = useState("");
const [mealType, setMealType] = useState<MealType>("Mittagessen");
const [portions, setPortions] = useState("1");
const [plannedCalories, setPlannedCalories] = useState("");
const [status, setStatus] = useState<string | null>(null);
const days = useMemo(() => weekDates(selectedDate), [selectedDate]);
const startsOn = days[0]!;
const endsOn = days[6]!;
const foodsQuery = trpc.health.listFoods.useQuery({ query: "" });
const recipesQuery = trpc.health.listRecipes.useQuery({ query: "" });
const plansQuery = trpc.health.mealPlansRange.useQuery({ startsOn, endsOn });
const savePlanMutation = trpc.health.upsertMealPlan.useMutation({
onSuccess: async () => {
await utils.health.mealPlansRange.invalidate({ startsOn, endsOn });
setStatus("Gericht geplant.");
},
});
const deletePlanMutation = trpc.health.deleteMealPlan.useMutation({
onSuccess: async () => {
await utils.health.mealPlansRange.invalidate({ startsOn, endsOn });
setStatus("Eintrag entfernt.");
},
});
const generateShoppingListMutation = trpc.health.generateShoppingList.useMutation({
onSuccess: async () => {
await utils.health.shoppingLists.invalidate();
setStatus("Einkaufsliste aus der aktuellen Woche erstellt.");
},
});
const foods = useMemo(() => (foodsQuery.data ?? []).map(apiFoodToCatalog), [foodsQuery.data]);
const recipes = useMemo(() => (recipesQuery.data ?? []).map(apiRecipeToRecipe), [recipesQuery.data]);
useEffect(() => {
if (recipeId || !recipes[0]) return;
setRecipeId(recipes[0].id);
}, [recipeId, recipes]);
const selectedMeals = useMemo(
() => (plansQuery.data ?? []).filter((meal) => meal.date === selectedDate),
[plansQuery.data, selectedDate],
);
const selectedRecipe = useMemo(() => recipes.find((item) => item.id === recipeId) ?? null, [recipeId, recipes]);
const calculatedCalories = useMemo(() => {
if (!selectedRecipe) return 0;
const totals = calcRecipe(selectedRecipe, foods, selectedRecipe.portions);
return Math.round(totals.kcal * (Number(portions) || 1));
}, [foods, portions, selectedRecipe]);
const mealsByType = useMemo(() => {
return mealTypes.reduce<Record<MealType, typeof selectedMeals>>((groups, type) => {
groups[type] = selectedMeals.filter((meal) => mealTypeFromApi[meal.mealType as ApiMealType] === type);
return groups;
}, {} as Record<MealType, typeof selectedMeals>);
}, [selectedMeals]);
const rangeLabel = `${parseDateKey(startsOn).toLocaleDateString("de-DE", { day: "2-digit", month: "short" })} - ${parseDateKey(endsOn).toLocaleDateString("de-DE", { day: "2-digit", month: "short" })}`;
function addPlannedMeal() {
const recipe = selectedRecipe;
if (!recipe) return;
savePlanMutation.mutate({
date: selectedDate,
mealType: mealTypeToApi[mealType],
recipeId: recipe.id,
name: recipe.name,
portions: Number(portions) || 1,
plannedCalories: Number(plannedCalories) || calculatedCalories,
note: recipe.notes ?? null,
});
}
function refreshShoppingList() {
generateShoppingListMutation.mutate({
startsOn,
endsOn,
name: `Einkauf ${rangeLabel}`,
});
}
return (
<div className="mx-auto max-w-5xl space-y-5 pb-24">
<header className="space-y-6 px-1 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="rounded-full p-2 text-gray-950 hover:bg-white" aria-label="Zurück" title="Zurück">
<ArrowLeftIcon className="h-7 w-7" />
</Link>
<Link href="/einstellungen" className="rounded-full p-2 text-gray-950 hover:bg-white" aria-label="Einstellungen" title="Einstellungen">
<Cog6ToothIcon className="h-7 w-7" />
</Link>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-5xl font-black tracking-normal text-gray-950">Ernährungsplan</h1>
<p className="mt-3 text-xl font-black text-gray-400">{rangeLabel}</p>
</div>
<button type="button" onClick={refreshShoppingList} className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 px-5 py-3 text-base font-black text-gray-950">
<ShoppingCartIcon className="h-5 w-5" />
Einkaufsliste erstellen
</button>
</div>
<div className="grid grid-cols-7 gap-2">
{days.map((dateKey) => {
const date = parseDateKey(dateKey);
const active = dateKey === selectedDate;
const count = (plansQuery.data ?? []).filter((meal) => meal.date === dateKey).length;
return (
<button
key={dateKey}
type="button"
onClick={() => setSelectedDate(dateKey)}
className={`flex min-h-20 flex-col items-center justify-center rounded-3xl text-sm ${
active ? "bg-white text-gray-950 shadow-sm ring-1 ring-gray-100" : "text-gray-400"
}`}
>
<span className="font-black uppercase">{date.toLocaleDateString("de-DE", { weekday: "narrow" })}</span>
<span className="text-2xl font-black">{date.getDate()}</span>
{count ? <span className="mt-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-700">{count}</span> : null}
</button>
);
})}
</div>
</header>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="grid gap-3 md:grid-cols-[1fr_150px_100px_110px_52px]">
<select value={recipeId} onChange={(event) => setRecipeId(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
{recipes.map((recipe) => (
<option key={recipe.id} value={recipe.id}>
{recipe.name}
</option>
))}
</select>
<select value={mealType} onChange={(event) => setMealType(event.target.value as MealType)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
{mealTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<input value={portions} onChange={(event) => setPortions(event.target.value)} placeholder="Portionen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={plannedCalories} onChange={(event) => setPlannedCalories(event.target.value)} placeholder={`${calculatedCalories || 0} kcal`} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<button type="button" onClick={addPlannedMeal} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Planen" title="Planen">
<PlusIcon className="h-5 w-5" />
</button>
</div>
{status ? <p className="mt-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
</section>
<div className="space-y-4">
{mealTypes.map((type) => (
<section key={type} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-400">{type}</h2>
<div className="mt-4 divide-y divide-gray-100">
{mealsByType[type].map((meal) => {
const recipe = recipes.find((item) => item.id === meal.recipeId);
const totals = recipe ? calcRecipe(recipe, foods, recipe.portions) : null;
const calories = Number(meal.plannedCalories) || (totals ? Math.round(totals.kcal * Number(meal.portions)) : 0);
return (
<div key={meal.id} className="flex items-center gap-4 py-4">
<RecipeAvatar name={meal.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-lg font-black leading-tight text-gray-950">{meal.name}</p>
<p className="mt-1 text-sm font-bold text-gray-400">
{Number(meal.portions)} Portion(en)
{calories ? ` · ${calories} kcal geplant` : ""}
</p>
</div>
<button type="button" onClick={() => deletePlanMutation.mutate({ id: meal.id })} className="grid h-10 w-10 place-items-center rounded-2xl bg-gray-100 text-gray-400" aria-label="Entfernen" title="Entfernen">
<TrashIcon className="h-5 w-5" />
</button>
</div>
);
})}
{!mealsByType[type].length ? <p className="py-4 text-sm font-bold text-gray-300">Noch nichts geplant.</p> : null}
</div>
</section>
))}
</div>
</div>
);
}