hellth-hub/apps/admin/app/(authed)/wochenbudget/page.tsx
Sebastian Mayer 7488916a9e
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run
Fix: ernährungsfachliche Auflagen aus der Abnahme umgesetzt
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>
2026-05-29 11:41:41 +02:00

214 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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