hellth-hub/apps/admin/app/(authed)/wochenbudget/page.tsx
Sebastian Mayer 339ac86b73
Some checks failed
CI / Lint, Typecheck, Test, Build (push) Has been cancelled
Fix: nicht-escaptes Anführungszeichen in wochenbudget/page.tsx behoben (CI-Lint)
Das gerade " war ein unescaped entity in JSX (react/no-unescaped-entities)
und liess den CI-Lint-Step mit Exit 1 fehlschlagen. Ans typografische
deutsche Schließzeichen angeglichen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:04:34 +02:00

214 lines
10 KiB
TypeScript
Raw Permalink 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>
);
}