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

202 lines
9.1 KiB
TypeScript
Raw Normal View History

"use client";
import {
ArrowTrendingDownIcon,
CalendarDaysIcon,
PlusIcon,
ScaleIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { todayKey } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
function formatDate(dateKey: string) {
return new Date(`${dateKey}T12:00:00`).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function WeightChart({ entries }: { entries: Array<{ id?: string; date: string; weightKg: number }> }) {
if (entries.length < 2) {
return (
<div className="grid min-h-56 place-items-center rounded-[2rem] bg-gray-50 p-6 text-center text-sm font-bold text-gray-400">
Noch zu wenig Messpunkte für einen Verlauf.
</div>
);
}
const min = Math.min(...entries.map((entry) => entry.weightKg));
const max = Math.max(...entries.map((entry) => entry.weightKg));
const range = Math.max(0.5, max - min);
return (
<div className="mt-5 flex min-h-64 items-end gap-2 rounded-[2rem] bg-gray-50 p-4">
{entries.map((entry) => {
const height = 18 + ((entry.weightKg - min) / range) * 76;
return (
<div key={entry.id ?? entry.date} className="flex min-w-8 flex-1 flex-col items-center justify-end gap-2">
<div className="w-full rounded-full bg-[#0F766E]" style={{ height: `${height}%` }} title={`${entry.weightKg} kg`} />
<p className="text-[10px] font-bold text-gray-400">{entry.date.slice(5)}</p>
</div>
);
})}
</div>
);
}
export default function GewichtPage() {
const utils = trpc.useUtils();
const [date, setDate] = useState(todayKey());
const [weightKg, setWeightKg] = useState("");
const [note, setNote] = useState("");
const [status, setStatus] = useState<string | null>(null);
const historyQuery = trpc.health.weightHistory.useQuery({ limit: 120 });
const saveMutation = trpc.health.upsertWeightLog.useMutation({
onSuccess: async () => {
await Promise.all([
utils.health.weightHistory.invalidate(),
utils.health.statsSummary.invalidate(),
utils.health.overview.invalidate({ date }),
]);
setWeightKg("");
setNote("");
setStatus("Gewicht gespeichert.");
},
});
const deleteMutation = trpc.health.deleteWeightLog.useMutation({
onSuccess: async () => {
await Promise.all([
utils.health.weightHistory.invalidate(),
utils.health.statsSummary.invalidate(),
]);
setStatus("Eintrag entfernt.");
},
});
const history = historyQuery.data;
const entries = history?.entries ?? [];
const latest = history?.latest;
const sortedRecent = useMemo(() => [...entries].reverse(), [entries]);
function saveWeight() {
const value = Number(weightKg.replace(",", "."));
if (!value || value <= 0) return;
saveMutation.mutate({
date,
weightKg: value,
note: note.trim() || null,
});
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="overflow-hidden rounded-[2rem] bg-gray-950 p-6 text-white shadow-sm">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-black uppercase text-emerald-300">Gewicht</p>
<h1 className="mt-2 text-4xl font-black tracking-normal">Verlauf statt Tageslaune</h1>
<p className="mt-3 max-w-3xl text-sm font-semibold text-white/60">
Einzelne Werte schwanken. Hellth Hub schaut auf den Trend und macht Prognosen erst, wenn genug echte Daten da sind.
</p>
</div>
<div className="grid h-28 w-28 place-items-center rounded-full bg-emerald-500 text-white ring-[12px] ring-emerald-500/20">
<ScaleIcon className="h-14 w-14" />
</div>
</div>
</header>
<section className="grid gap-4 lg:grid-cols-[1fr_0.75fr]">
<section 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>
<h2 className="text-xl font-black text-gray-950">Gewichtshistorie</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">{entries.length} Messpunkt(e) in der Datenbank</p>
</div>
<div className="rounded-full bg-gray-100 px-4 py-2 text-sm font-black text-gray-600">
Ziel: {history?.targetWeightKg ? `${history.targetWeightKg} kg` : "nicht gesetzt"}
</div>
</div>
<WeightChart entries={entries} />
</section>
<aside className="space-y-4">
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<PlusIcon className="h-7 w-7 text-[#0F766E]" />
<h2 className="text-xl font-black text-gray-950">Messwert eintragen</h2>
</div>
<div className="mt-4 grid gap-3">
<input type="date" value={date} onChange={(event) => setDate(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={weightKg} onChange={(event) => setWeightKg(event.target.value)} placeholder={latest ? `${latest.weightKg} kg` : "Gewicht kg"} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<textarea value={note} onChange={(event) => setNote(event.target.value)} placeholder="Notiz optional" className="min-h-20 rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<button type="button" onClick={saveWeight} className="rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white">
Speichern
</button>
{status ? <p className="rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
</div>
</section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<ArrowTrendingDownIcon className="h-7 w-7 text-emerald-600" />
<h2 className="text-xl font-black text-gray-950">Prognose</h2>
</div>
<p className="mt-3 text-sm font-semibold text-gray-500">{history?.projectionMessage ?? "Noch keine Prognose verfügbar."}</p>
{history?.targetDate ? (
<p className="mt-4 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-black text-emerald-800">
Hochrechnung Zieltermin: {formatDate(history.targetDate)}
</p>
) : null}
<p className="mt-3 text-xs font-bold text-gray-400">
Vertrauen: {history?.confidence === "medium" ? "mittel" : history?.confidence === "low" ? "niedrig" : "noch nicht genug Daten"}
</p>
</section>
</aside>
</section>
<section className="grid gap-4 md:grid-cols-3">
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-gray-400">Aktuell</p>
<p className="mt-4 text-4xl font-black text-gray-950">{latest ? `${latest.weightKg} kg` : "-"}</p>
</div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-gray-400">Seit Start</p>
<p className="mt-4 text-4xl font-black text-gray-950">{history ? `${history.deltaFromFirst} kg` : "-"}</p>
</div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-gray-400">Letzte Änderung</p>
<p className="mt-4 text-4xl font-black text-gray-950">{history ? `${history.deltaFromPrevious} kg` : "-"}</p>
</div>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<CalendarDaysIcon className="h-7 w-7 text-gray-400" />
<h2 className="text-xl font-black text-gray-950">Messwerte</h2>
</div>
<div className="mt-4 divide-y divide-gray-100">
{sortedRecent.map((entry) => (
<div key={entry.id} className="flex items-center gap-4 py-4">
<div className="min-w-0 flex-1">
<p className="text-lg font-black text-gray-950">{entry.weightKg} kg</p>
<p className="text-sm font-bold text-gray-400">
{formatDate(entry.date)}
{entry.note ? ` · ${entry.note}` : ""}
</p>
</div>
<button type="button" onClick={() => deleteMutation.mutate({ id: entry.id })} className="grid h-10 w-10 place-items-center rounded-2xl bg-gray-100 text-gray-400" aria-label="Löschen" title="Löschen">
<TrashIcon className="h-5 w-5" />
</button>
</div>
))}
{!entries.length ? <p className="py-5 text-sm font-bold text-gray-400">Noch keine Messwerte gespeichert.</p> : null}
</div>
</section>
</div>
);
}