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

166 lines
8.5 KiB
TypeScript
Raw Normal View History

"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>
);
}