Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run
Nachbesserungen aus der fachlichen Begutachtung (Skill ernaehrungswissenschaftler): - Kalorien-Untergrenze von 800 auf 1.200 kcal angehoben (goalsInput und upsertWeeklyCaloriePlan, neue Konstante MIN_CALORIE_GOAL). Im Ziele-Tab Sicherheitswarnung bei einem Ziel unter 1.500 kcal. - Defizit-Banner wird nur noch bei moderatem Defizit (10-25 % unter dem Basis-Tagesziel) gefeiert und gegen baseDailyGoal statt inkl. Sportbonus gerechnet. Bei einem Defizit über 25 % erscheint stattdessen ein sanfter "iss genug"-Hinweis statt Lob. - Wochenbudget-Verteilung: der am 1.200-Floor gekappte Rest wird iterativ auf die übrigen Tage weiterverteilt, sodass das Wochenbudget erhalten bleibt, solange es geht. Neue Funktion weeklyExtrasOverflow meldet den unvermeidbaren Überschuss; die Wochenbudget-Seite weist ihn ehrlich aus statt pauschal "bleibt gleich" zu versprechen. - Dashboard-Makros: Kohlenhydrate und Fett als geschätzt (~) gekennzeichnet inkl. erklärendem Hinweis; Protein bleibt real getrackt. - 2 neue API-Tests (iterative Floor-Verteilung, weeklyExtrasOverflow). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
10 KiB
TypeScript
214 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|