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

215 lines
10 KiB
TypeScript
Raw Normal View History

"use client";
import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
import { useEffect, 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" };
}
type ExtraDraft = { extraCalories: string; note: string };
export default function WochenbudgetPage() {
const utils = trpc.useUtils();
const [selectedDate, setSelectedDate] = useState(todayKey());
const [saved, setSaved] = useState(false);
// Pro Tag eingeplanter Mehrbedarf ("Blocker"), bevor er verteilt wird.
const [drafts, setDrafts] = useState<Record<string, ExtraDraft>>({});
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate });
const summary = summaryQuery.data?.week ?? null;
const prediction = summary ? prognosis(summary.balance) : null;
const setExtrasMutation = trpc.health.setWeeklyExtras.useMutation({
onSuccess: async () => {
// Ohne Key-Filter invalidieren, damit Dashboard + Statistiken mitziehen.
await utils.health.statsSummary.invalidate();
await utils.health.overview.invalidate();
setSaved(true);
window.setTimeout(() => setSaved(false), 1500);
},
});
// Entwurf aus den gespeicherten Tageswerten vorbefüllen, sobald die Woche geladen ist.
useEffect(() => {
if (!summary) return;
setDrafts((current) => {
const next: Record<string, ExtraDraft> = {};
for (const day of summary.days) {
next[day.date] = current[day.date] ?? {
extraCalories: day.extraCalories ? String(day.extraCalories) : "",
note: day.note ?? "",
};
}
return next;
});
}, [summary]);
const totalExtra = useMemo(
() => Object.values(drafts).reduce((sum, draft) => sum + (Number(draft.extraCalories) || 0), 0),
[drafts],
);
// Wenn der 1.200-kcal-Floor griff, liegt die Summe der gespeicherten Tagesziele ueber
// dem Wochenziel. Diesen unvermeidbaren Ueberschuss ehrlich ausweisen.
const weeklyOverflow = useMemo(() => {
if (!summary) return 0;
const planned = summary.days.reduce((sum, day) => sum + day.plannedCalories, 0);
return Math.max(0, planned - summary.baseBudget);
}, [summary]);
function updateDraft(date: string, patch: Partial<ExtraDraft>) {
setDrafts((current) => ({ ...current, [date]: { ...current[date], ...patch } as ExtraDraft }));
}
function redistribute() {
if (!summary) return;
setExtrasMutation.mutate({
weekStart: summary.weekStart,
days: summary.days.map((day) => ({
date: day.date,
extraCalories: Math.max(0, Number(drafts[day.date]?.extraCalories) || 0),
note: drafts[day.date]?.note?.trim() || null,
})),
});
}
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">
Trag pro Tag ein, wenn du mehr brauchst (z. B. Familienfeier +800). Mit
„kcal neu verteilen" hebt Hellth Hub diese Tage an und gleicht den Rest der Woche
gleichmäßig aus – solange kein Tag unter 1.200 kcal fällt, bleibt dein Wochenziel gleich.
</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 (Woche)</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">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-black text-gray-950">Tagesverteilung</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">
{totalExtra > 0
? `${fmt(totalExtra)} kcal Mehrbedarf eingeplant – wird beim Verteilen auf die übrigen Tage ausgeglichen.`
: "Trag bei einzelnen Tagen einen Mehrbedarf ein und verteile anschließend."}
</p>
{weeklyOverflow > 0 ? (
<p className="mt-2 flex items-start gap-1.5 text-sm font-semibold text-amber-700">
<span aria-hidden>ⓘ</span>
Der Ausgleich stößt an die 1.200-kcal-Untergrenze: Dein Wochenbudget liegt diese
Woche um {fmt(weeklyOverflow)} kcal höher, damit kein Tag zu niedrig wird.
</p>
) : null}
</div>
<button
type="button"
onClick={redistribute}
disabled={setExtrasMutation.isPending}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-gray-950 px-5 py-3 text-sm font-black text-white disabled:opacity-40"
>
<ArrowPathIcon className="h-5 w-5" />
{setExtrasMutation.isPending ? "Verteile…" : "kcal neu verteilen"}
</button>
</div>
<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;
const draft = drafts[day.date] ?? { extraCalories: "", note: "" };
const isBlocker = (Number(draft.extraCalories) || 0) > 0;
return (
<div key={day.date} className={`grid gap-3 rounded-3xl p-4 md:grid-cols-[120px_1fr_150px_150px_110px] md:items-center ${isBlocker ? "bg-amber-50 ring-1 ring-amber-200" : "bg-gray-50"}`}>
<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` : ""}
</p>
</div>
<label className="flex items-center gap-2">
<span className="text-sm font-black text-gray-400">+</span>
<input
type="number"
inputMode="numeric"
min={0}
value={draft.extraCalories}
onChange={(event) => updateDraft(day.date, { extraCalories: event.target.value })}
placeholder="kcal"
className="w-full rounded-2xl border border-gray-200 px-3 py-3 text-sm font-bold"
/>
</label>
<input
value={draft.note}
onChange={(event) => updateDraft(day.date, { note: event.target.value })}
placeholder="Anlass"
className="rounded-2xl border border-gray-200 px-3 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>
{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>
);
}