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>
165 lines
8.5 KiB
TypeScript
165 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
|
|
import { useMemo, useState } from "react";
|
|
import { todayKey } from "@/lib/hellth-data";
|
|
import { trpc } from "@/lib/trpc";
|
|
|
|
function fmt(value: number) {
|
|
return Math.round(value).toLocaleString("de-DE");
|
|
}
|
|
|
|
function prognosis(balance: number) {
|
|
if (balance >= 1000) return { label: "entspannt im Ziel", color: "text-emerald-700 bg-emerald-50" };
|
|
if (balance >= -300) return { label: "gut steuerbar", color: "text-blue-700 bg-blue-50" };
|
|
return { label: "leicht drüber - flexibel ausgleichen", color: "text-amber-700 bg-amber-50" };
|
|
}
|
|
|
|
export default function WochenbudgetPage() {
|
|
const utils = trpc.useUtils();
|
|
const [selectedDate, setSelectedDate] = useState(todayKey());
|
|
const [extraDate, setExtraDate] = useState(todayKey());
|
|
const [extraCalories, setExtraCalories] = useState("800");
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate });
|
|
const summary = summaryQuery.data?.week ?? null;
|
|
const prediction = summary ? prognosis(summary.balance) : null;
|
|
const upsertPlanMutation = trpc.health.upsertWeeklyCaloriePlan.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.statsSummary.invalidate({ referenceDate: selectedDate });
|
|
setSaved(true);
|
|
window.setTimeout(() => setSaved(false), 1500);
|
|
},
|
|
});
|
|
|
|
const dayByDate = useMemo(() => new Map((summary?.days ?? []).map((day) => [day.date, day])), [summary?.days]);
|
|
|
|
function updatePlan(date: string, plannedCalories: number, note?: string) {
|
|
upsertPlanMutation.mutate({
|
|
date,
|
|
plannedCalories: Math.max(800, Math.min(6000, Math.round(plannedCalories))),
|
|
note: note ?? dayByDate.get(date)?.note ?? null,
|
|
});
|
|
}
|
|
|
|
function applyRestaurantDistribution() {
|
|
if (!summary) return;
|
|
const extra = Math.max(0, Number(extraCalories) || 0);
|
|
const days = summary.days;
|
|
const targetDay = days.find((day) => day.date === extraDate);
|
|
if (!targetDay) return;
|
|
|
|
const otherDays = days.filter((day) => day.date !== extraDate);
|
|
const savingPerOtherDay = otherDays.length ? Math.ceil(extra / otherDays.length) : 0;
|
|
|
|
updatePlan(extraDate, targetDay.plannedCalories + extra, targetDay.note ?? "Restaurant / höherer Tag");
|
|
for (const day of otherDays) {
|
|
updatePlan(day.date, Math.max(1200, day.plannedCalories - savingPerOtherDay), day.note);
|
|
}
|
|
}
|
|
|
|
if (!summary || !prediction) {
|
|
return (
|
|
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
|
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
|
<p className="text-sm font-black text-gray-400">Wochenbudget wird geladen.</p>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
|
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="text-sm font-black uppercase text-[#0F766E]">Flexible Planung</p>
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Wochenbudget</h1>
|
|
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
|
|
Ein höherer Tag ist kein Problem. Hellth Hub verteilt den Ausgleich freundlich auf die Woche.
|
|
</p>
|
|
</div>
|
|
<input type="date" value={selectedDate} onChange={(event) => setSelectedDate(event.target.value || todayKey())} className="rounded-full border border-gray-200 px-4 py-3 text-sm font-bold" />
|
|
</div>
|
|
</header>
|
|
|
|
<section className="grid gap-4 lg:grid-cols-4">
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<FireIcon className="h-8 w-8 text-orange-500" />
|
|
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.baseBudget)}</p>
|
|
<p className="text-sm font-bold text-gray-400">Wochenziel</p>
|
|
</div>
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<CalendarDaysIcon className="h-8 w-8 text-blue-500" />
|
|
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.eatenCalories)}</p>
|
|
<p className="text-sm font-bold text-gray-400">gegessen/geplant</p>
|
|
</div>
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<SparklesIcon className="h-8 w-8 text-emerald-500" />
|
|
<p className="mt-3 text-3xl font-black text-gray-950">+{fmt(summary.activityCalories)}</p>
|
|
<p className="text-sm font-bold text-gray-400">Sportbonus</p>
|
|
</div>
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<div className={`inline-flex rounded-full px-3 py-1 text-xs font-black ${prediction.color}`}>{prediction.label}</div>
|
|
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.availableCalories)}</p>
|
|
<p className="text-sm font-bold text-gray-400">noch verfügbar</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Tagesverteilung</h2>
|
|
<div className="mt-5 grid gap-3">
|
|
{summary.days.map((day) => {
|
|
const date = new Date(`${day.date}T12:00:00`);
|
|
const over = day.eatenCalories > day.plannedCalories + day.activityCalories;
|
|
return (
|
|
<div key={day.date} className="grid gap-3 rounded-3xl bg-gray-50 p-4 md:grid-cols-[130px_1fr_160px_120px] md:items-center">
|
|
<div>
|
|
<p className="text-lg font-black text-gray-950">{date.toLocaleDateString("de-DE", { weekday: "short" })}</p>
|
|
<p className="text-xs font-bold text-gray-400">{date.toLocaleDateString("de-DE")}</p>
|
|
</div>
|
|
<div>
|
|
<div className="h-3 overflow-hidden rounded-full bg-white">
|
|
<div className={`h-full rounded-full ${over ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${Math.min(100, (day.eatenCalories / Math.max(1, day.plannedCalories + day.activityCalories)) * 100)}%` }} />
|
|
</div>
|
|
<p className="mt-2 text-sm font-bold text-gray-400">
|
|
{fmt(day.eatenCalories)} / {fmt(day.plannedCalories)} kcal
|
|
{day.activityCalories ? ` + ${fmt(day.activityCalories)} Sport` : ""}
|
|
{day.note ? ` - ${day.note}` : ""}
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
value={day.plannedCalories}
|
|
onChange={(event) => updatePlan(day.date, Number(event.target.value) || summary.dailyGoal, day.note)}
|
|
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold"
|
|
/>
|
|
<p className={`text-sm font-black ${day.balance >= 0 ? "text-emerald-700" : "text-amber-700"}`}>
|
|
{day.balance >= 0 ? "+" : ""}{fmt(day.balance)} kcal
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Restaurantbesuch einplanen</h2>
|
|
<p className="mt-1 text-sm font-semibold text-gray-400">
|
|
Beispiel: Samstag +800 kcal. Die App verteilt den Ausgleich auf die restlichen Tage.
|
|
</p>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_160px_180px]">
|
|
<input type="date" value={extraDate} onChange={(event) => setExtraDate(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" />
|
|
<input value={extraCalories} onChange={(event) => setExtraCalories(event.target.value)} placeholder="+ kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" />
|
|
<button type="button" onClick={applyRestaurantDistribution} disabled={upsertPlanMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
|
|
<ArrowPathIcon className="h-5 w-5" />
|
|
Verteilen
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{saved ? <div className="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-lg">Gespeichert</div> : null}
|
|
</div>
|
|
);
|
|
}
|