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>
324 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|