2026-05-27 22:15:16 +02:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
|
2026-05-29 11:30:29 +02:00
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
2026-05-27 22:15:16 +02:00
|
|
|
|
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" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 11:30:29 +02:00
|
|
|
|
type ExtraDraft = { extraCalories: string; note: string };
|
|
|
|
|
|
|
2026-05-27 22:15:16 +02:00
|
|
|
|
export default function WochenbudgetPage() {
|
|
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
|
|
const [selectedDate, setSelectedDate] = useState(todayKey());
|
|
|
|
|
|
const [saved, setSaved] = useState(false);
|
2026-05-29 11:30:29 +02:00
|
|
|
|
// Pro Tag eingeplanter Mehrbedarf ("Blocker"), bevor er verteilt wird.
|
|
|
|
|
|
const [drafts, setDrafts] = useState<Record<string, ExtraDraft>>({});
|
2026-05-27 22:15:16 +02:00
|
|
|
|
|
|
|
|
|
|
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate });
|
|
|
|
|
|
const summary = summaryQuery.data?.week ?? null;
|
|
|
|
|
|
const prediction = summary ? prognosis(summary.balance) : null;
|
2026-05-29 11:30:29 +02:00
|
|
|
|
|
|
|
|
|
|
const setExtrasMutation = trpc.health.setWeeklyExtras.useMutation({
|
2026-05-27 22:15:16 +02:00
|
|
|
|
onSuccess: async () => {
|
2026-05-29 11:30:29 +02:00
|
|
|
|
// Ohne Key-Filter invalidieren, damit Dashboard + Statistiken mitziehen.
|
|
|
|
|
|
await utils.health.statsSummary.invalidate();
|
|
|
|
|
|
await utils.health.overview.invalidate();
|
2026-05-27 22:15:16 +02:00
|
|
|
|
setSaved(true);
|
|
|
|
|
|
window.setTimeout(() => setSaved(false), 1500);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-29 11:30:29 +02:00
|
|
|
|
// 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;
|
2026-05-27 22:15:16 +02:00
|
|
|
|
});
|
2026-05-29 11:30:29 +02:00
|
|
|
|
}, [summary]);
|
|
|
|
|
|
|
|
|
|
|
|
const totalExtra = useMemo(
|
|
|
|
|
|
() => Object.values(drafts).reduce((sum, draft) => sum + (Number(draft.extraCalories) || 0), 0),
|
|
|
|
|
|
[drafts],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function updateDraft(date: string, patch: Partial<ExtraDraft>) {
|
|
|
|
|
|
setDrafts((current) => ({ ...current, [date]: { ...current[date], ...patch } as ExtraDraft }));
|
2026-05-27 22:15:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 11:30:29 +02:00
|
|
|
|
function redistribute() {
|
2026-05-27 22:15:16 +02:00
|
|
|
|
if (!summary) return;
|
2026-05-29 11:30:29 +02:00
|
|
|
|
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,
|
|
|
|
|
|
})),
|
|
|
|
|
|
});
|
2026-05-27 22:15:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
2026-05-29 11:30:29 +02:00
|
|
|
|
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. Dein Wochenziel bleibt gleich.
|
2026-05-27 22:15:16 +02:00
|
|
|
|
</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>
|
2026-05-29 11:30:29 +02:00
|
|
|
|
<p className="text-sm font-bold text-gray-400">Sportbonus (Woche)</p>
|
2026-05-27 22:15:16 +02:00
|
|
|
|
</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">
|
2026-05-29 11:30:29 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-05-27 22:15:16 +02:00
|
|
|
|
<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;
|
2026-05-29 11:30:29 +02:00
|
|
|
|
const draft = drafts[day.date] ?? { extraCalories: "", note: "" };
|
|
|
|
|
|
const isBlocker = (Number(draft.extraCalories) || 0) > 0;
|
2026-05-27 22:15:16 +02:00
|
|
|
|
return (
|
2026-05-29 11:30:29 +02:00
|
|
|
|
<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"}`}>
|
2026-05-27 22:15:16 +02:00
|
|
|
|
<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>
|
2026-05-29 11:30:29 +02:00
|
|
|
|
<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>
|
2026-05-27 22:15:16 +02:00
|
|
|
|
<input
|
2026-05-29 11:30:29 +02:00
|
|
|
|
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"
|
2026-05-27 22:15:16 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|