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

214 lines
9.1 KiB
TypeScript
Raw Normal View History

"use client";
import {
ArrowTrendingDownIcon,
BoltIcon,
ChartBarIcon,
FireIcon,
ScaleIcon,
SparklesIcon,
TrophyIcon,
} from "@heroicons/react/24/outline";
import { useMemo } from "react";
import { HealthStatsSummary, 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",
});
}
function StatCard({
title,
value,
detail,
tone = "gray",
icon: Icon,
}: {
title: string;
value: string;
detail: string;
tone?: "gray" | "green" | "blue" | "amber" | "rose";
icon: React.ComponentType<{ className?: string }>;
}) {
const tones = {
gray: "bg-gray-50 text-gray-950",
green: "bg-emerald-50 text-emerald-800",
blue: "bg-blue-50 text-blue-800",
amber: "bg-amber-50 text-amber-800",
rose: "bg-rose-50 text-rose-800",
};
return (
<article className={`rounded-[2rem] p-5 ${tones[tone]}`}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-black uppercase opacity-60">{title}</p>
<Icon className="h-6 w-6 opacity-70" />
</div>
<p className="mt-5 text-4xl font-black tracking-normal">{value}</p>
<p className="mt-2 text-sm font-bold opacity-70">{detail}</p>
</article>
);
}
function MiniBars({ summary }: { summary: HealthStatsSummary }) {
const maxCalories = Math.max(...summary.days.map((day) => day.calories + day.activityCalories), summary.week.dailyGoal, 1);
return (
<div className="mt-5 grid grid-cols-14 gap-1">
{summary.days.map((day) => {
const height = Math.max(8, Math.round((day.calories / maxCalories) * 100));
const activityHeight = Math.round((day.activityCalories / maxCalories) * 100);
return (
<div key={day.date} className="flex min-h-44 flex-col justify-end gap-1">
<div className="flex h-32 flex-col justify-end rounded-full bg-gray-100 p-1">
{activityHeight > 0 ? (
<div className="rounded-full bg-emerald-400" style={{ height: `${Math.max(6, activityHeight)}%` }} />
) : null}
<div className="rounded-full bg-gray-950" style={{ height: `${height}%` }} />
</div>
<p className="text-center text-[10px] font-bold text-gray-400">{formatDate(day.date)}</p>
</div>
);
})}
</div>
);
}
function ProjectionCard({ summary }: { summary: HealthStatsSummary }) {
if (!summary.projection) {
return (
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start gap-3">
<ScaleIcon className="h-7 w-7 text-gray-400" />
<div>
<h2 className="text-xl font-black text-gray-950">Prognose</h2>
<p className="mt-2 text-sm font-semibold text-gray-400">
Noch zu wenig Gewichtsdaten für eine faire Prognose. Ab drei Messungen über mindestens eine Woche wird hier ein vorsichtiger Trend angezeigt.
</p>
</div>
</div>
</section>
);
}
return (
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start gap-3">
<ArrowTrendingDownIcon className="h-7 w-7 text-emerald-600" />
<div className="min-w-0">
<h2 className="text-xl font-black text-gray-950">Prognose</h2>
<p className="mt-2 text-sm font-semibold text-gray-500">{summary.projection.message}</p>
{summary.projection.targetDate ? (
<p className="mt-4 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-black text-emerald-800">
Hochrechnung Zieltermin: {new Date(`${summary.projection.targetDate}T12:00:00`).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" })}
</p>
) : null}
<p className="mt-3 text-xs font-bold text-gray-400">
Vertrauen: {summary.projection.confidence === "medium" ? "mittel" : "niedrig"} · nur als Orientierung, nicht als Urteil.
</p>
</div>
</div>
</section>
);
}
export default function StatistikenPage() {
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: todayKey() });
const summary = summaryQuery.data as HealthStatsSummary | undefined;
const weekStatus = useMemo(() => {
if (!summary) return "Sammelt Daten";
if (summary.week.balance > 800) return "entspannt im Rahmen";
if (summary.week.balance >= -300) return "ziemlich genau auf Kurs";
return "leicht über Plan, gut ausgleichbar";
}, [summary]);
if (!summary) {
return <div className="mx-auto max-w-6xl pb-24 text-sm font-bold text-gray-400">Statistiken werden geladen...</div>;
}
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">Statistiken</p>
<h1 className="mt-2 text-4xl font-black tracking-normal">Fortschritt, der sich gut anfühlt</h1>
<p className="mt-3 max-w-3xl text-sm font-semibold text-white/60">
Trends, kleine Siege und Prognosen, wenn die Daten dafür stark genug 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">
<ChartBarIcon className="h-14 w-14" />
</div>
</div>
</header>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard title="Woche" value={weekStatus} detail={`${summary.week.availableCalories.toLocaleString("de-DE")} kcal verfügbar`} tone="green" icon={FireIcon} />
<StatCard title="Protein" value={`${summary.averages.protein} g`} detail="Durchschnitt der letzten 14 Tage" tone="blue" icon={SparklesIcon} />
<StatCard title="Trinken" value={`${summary.averages.drinkMl} ml`} detail={`${summary.streaks.waterDays} Tag(e) Zielserie`} tone="gray" icon={BoltIcon} />
<StatCard title="Bewegung" value={`${summary.averages.activityCalories} kcal`} detail={`${summary.streaks.activityDays} Tag(e) in Folge aktiv`} tone="amber" icon={TrophyIcon} />
</section>
<section className="grid gap-4 lg:grid-cols-[1fr_0.8fr]">
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-xl font-black text-gray-950">14-Tage-Verlauf</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">Schwarz: Essen/Planung · Grün: Sportbonus</p>
</div>
<p className="rounded-full bg-gray-100 px-4 py-2 text-sm font-black text-gray-600">
Ø {summary.averages.calories.toLocaleString("de-DE")} kcal
</p>
</div>
<MiniBars summary={summary} />
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<TrophyIcon className="h-7 w-7 text-amber-500" />
<h2 className="text-xl font-black text-gray-950">Kleine Siege</h2>
</div>
<div className="mt-5 space-y-3">
{summary.wins.map((win) => (
<p key={win} className="rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold text-gray-600">
{win}
</p>
))}
</div>
</section>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<ProjectionCard summary={summary} />
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Wochenbudget</h2>
<div className="mt-5 grid grid-cols-2 gap-3">
<div className="rounded-3xl bg-gray-50 p-4">
<p className="text-2xl font-black">{summary.week.plannedBudget.toLocaleString("de-DE")}</p>
<p className="text-xs font-bold text-gray-400">geplantes Budget</p>
</div>
<div className="rounded-3xl bg-gray-50 p-4">
<p className="text-2xl font-black">{summary.week.eatenCalories.toLocaleString("de-DE")}</p>
<p className="text-xs font-bold text-gray-400">gegessen/geplant</p>
</div>
<div className="rounded-3xl bg-emerald-50 p-4">
<p className="text-2xl font-black text-emerald-700">+{summary.week.activityCalories.toLocaleString("de-DE")}</p>
<p className="text-xs font-bold text-emerald-700">Sportbonus</p>
</div>
<div className="rounded-3xl bg-blue-50 p-4">
<p className="text-2xl font-black text-blue-700">{summary.week.availableCalories.toLocaleString("de-DE")}</p>
<p className="text-xs font-bold text-blue-700">Restbudget</p>
</div>
</div>
</section>
</section>
</div>
);
}