Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
9.1 KiB
TypeScript
213 lines
9.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|
|
|