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>
371 lines
17 KiB
TypeScript
371 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
BoltIcon,
|
|
CalendarDaysIcon,
|
|
FireIcon,
|
|
PlusIcon,
|
|
SparklesIcon,
|
|
TrashIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
import { useMemo, useState } from "react";
|
|
import { trpc } from "@/lib/trpc";
|
|
|
|
type TemplateCategory = "mobility" | "cardio" | "strength" | "habit" | string;
|
|
type ActivitySource = "manual" | "fitbit";
|
|
|
|
function todayKey() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
function startOfWeek(dateKey: string) {
|
|
const date = new Date(`${dateKey}T12:00:00`);
|
|
const day = date.getDay() === 0 ? 7 : date.getDay();
|
|
date.setDate(date.getDate() - day + 1);
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function pointsLabel(points: number) {
|
|
if (points >= 500) return "Starke Woche";
|
|
if (points >= 250) return "Guter Lauf";
|
|
if (points >= 100) return "In Bewegung";
|
|
return "Startklar";
|
|
}
|
|
|
|
function categoryColor(category: TemplateCategory) {
|
|
if (category.includes("cardio")) return "bg-orange-50 text-orange-700";
|
|
if (category.includes("strength")) return "bg-blue-50 text-blue-700";
|
|
if (category.includes("mobility")) return "bg-emerald-50 text-emerald-700";
|
|
if (category.includes("sport")) return "bg-amber-50 text-amber-700";
|
|
return "bg-purple-50 text-purple-700";
|
|
}
|
|
|
|
function categoryLabel(category: TemplateCategory) {
|
|
if (category.includes("cardio")) return "Ausdauer";
|
|
if (category.includes("strength")) return "Kraft";
|
|
if (category.includes("mobility")) return "Beweglichkeit";
|
|
if (category.includes("sport")) return "Sport";
|
|
return "Routine";
|
|
}
|
|
|
|
function sourceLabel(source: string) {
|
|
if (source === "fitbit") return "Fitbit";
|
|
if (source === "health_connect") return "Health Connect";
|
|
return "manuell";
|
|
}
|
|
|
|
function estimatePoints(minutes: number, calories: number) {
|
|
return Math.max(5, Math.round(minutes * 1.2 + calories / 6));
|
|
}
|
|
|
|
export default function AktivitätenPage() {
|
|
const utils = trpc.useUtils();
|
|
const [selectedDate, setSelectedDate] = useState(todayKey());
|
|
const [templateQuery, setTemplateQuery] = useState("");
|
|
const [templateCategory, setTemplateCategory] = useState<"all" | "fitbit" | "manual">("all");
|
|
const [customName, setCustomName] = useState("");
|
|
const [customMinutes, setCustomMinutes] = useState("15");
|
|
const [customPoints, setCustomPoints] = useState("30");
|
|
const [customCalories, setCustomCalories] = useState("60");
|
|
const [customNote, setCustomNote] = useState("");
|
|
const [customSource, setCustomSource] = useState<ActivitySource>("manual");
|
|
|
|
const templatesQuery = trpc.health.listActivityTemplates.useQuery();
|
|
const activityWeekQuery = trpc.health.activityWeek.useQuery({ date: selectedDate });
|
|
const addLogMutation = trpc.health.addActivityLog.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.activityWeek.invalidate();
|
|
await utils.health.overview.invalidate();
|
|
},
|
|
});
|
|
const saveTemplateMutation = trpc.health.saveActivityTemplate.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.listActivityTemplates.invalidate();
|
|
setCustomName("");
|
|
},
|
|
});
|
|
const deleteLogMutation = trpc.health.deleteActivityLog.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.activityWeek.invalidate();
|
|
await utils.health.overview.invalidate();
|
|
},
|
|
});
|
|
|
|
const logs = activityWeekQuery.data?.logs ?? [];
|
|
const templates = templatesQuery.data ?? [];
|
|
const filteredTemplates = useMemo(() => {
|
|
const needle = templateQuery.trim().toLowerCase();
|
|
return templates.filter((template) => {
|
|
const isFitbit = template.id.startsWith("fitbit-") || template.category.startsWith("fitbit_");
|
|
if (templateCategory === "fitbit" && !isFitbit) return false;
|
|
if (templateCategory === "manual" && isFitbit) return false;
|
|
if (!needle) return true;
|
|
return [template.name, template.category].some((value) => value.toLowerCase().includes(needle));
|
|
});
|
|
}, [templateCategory, templateQuery, templates]);
|
|
const todayLogs = logs.filter((log) => log.date === selectedDate);
|
|
const dayPoints = todayLogs.reduce((sum, log) => sum + log.points, 0);
|
|
const weekPoints = logs.reduce((sum, log) => sum + log.points, 0);
|
|
const weekMinutes = logs.reduce((sum, log) => sum + log.minutes, 0);
|
|
const weekCalories = logs.reduce((sum, log) => sum + (log.calories ?? 0), 0);
|
|
|
|
const weeklyDays = useMemo(() => {
|
|
const monday = new Date(`${startOfWeek(selectedDate)}T12:00:00`);
|
|
return Array.from({ length: 7 }, (_, index) => {
|
|
const date = new Date(monday);
|
|
date.setDate(monday.getDate() + index);
|
|
const key = date.toISOString().slice(0, 10);
|
|
const points = logs.filter((log) => log.date === key).reduce((sum, log) => sum + log.points, 0);
|
|
return { key, label: date.toLocaleDateString("de-DE", { weekday: "narrow" }), day: date.getDate(), points };
|
|
});
|
|
}, [logs, selectedDate]);
|
|
|
|
function logTemplate(template: (typeof templates)[number]) {
|
|
const isFitbit = template.id.startsWith("fitbit-") || template.category.startsWith("fitbit_");
|
|
addLogMutation.mutate({
|
|
date: selectedDate,
|
|
name: template.name,
|
|
templateId: template.id,
|
|
minutes: template.defaultMinutes,
|
|
points: template.defaultPoints,
|
|
calories: template.defaultCalories,
|
|
source: isFitbit ? "fitbit" : "manual",
|
|
});
|
|
}
|
|
|
|
function saveCustomTemplate() {
|
|
if (!customName.trim()) return;
|
|
saveTemplateMutation.mutate({
|
|
name: customName.trim(),
|
|
defaultMinutes: Number(customMinutes) || 5,
|
|
defaultPoints: Number(customPoints) || 10,
|
|
defaultCalories: Number(customCalories) || 0,
|
|
category: "habit",
|
|
icon: "custom",
|
|
});
|
|
}
|
|
|
|
function addCustomLog() {
|
|
if (!customName.trim()) return;
|
|
const minutes = Number(customMinutes) || 5;
|
|
const calories = Number(customCalories) || 0;
|
|
const points = Number(customPoints) || estimatePoints(minutes, calories);
|
|
addLogMutation.mutate({
|
|
date: selectedDate,
|
|
name: customName.trim(),
|
|
minutes,
|
|
points,
|
|
calories,
|
|
source: customSource,
|
|
note: customNote.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-5 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="text-sm font-black uppercase text-emerald-300">Belohnungssystem</p>
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal">Aktivitätspunkte</h1>
|
|
<p className="mt-3 max-w-2xl text-sm font-semibold text-white/60">
|
|
Fitbit-kompatibler Katalog, verbrannte kcal und manuelle Nacherfassung für Training ohne Uhr.
|
|
</p>
|
|
</div>
|
|
<div className="grid h-28 w-28 place-items-center rounded-full bg-white text-gray-950 ring-[12px] ring-white/10">
|
|
<div className="text-center">
|
|
<p className="text-3xl font-black">{dayPoints}</p>
|
|
<p className="text-xs font-black text-gray-400">heute</p>
|
|
</div>
|
|
</div>
|
|
</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">
|
|
<div className="flex items-center gap-3">
|
|
<BoltIcon className="h-8 w-8 text-emerald-500" />
|
|
<div>
|
|
<p className="text-3xl font-black text-gray-950">{weekPoints}</p>
|
|
<p className="text-sm font-bold text-gray-400">Punkte diese Woche</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<div className="flex items-center gap-3">
|
|
<CalendarDaysIcon className="h-8 w-8 text-blue-500" />
|
|
<div>
|
|
<p className="text-3xl font-black text-gray-950">{weekMinutes}</p>
|
|
<p className="text-sm font-bold text-gray-400">aktive Minuten</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<div className="flex items-center gap-3">
|
|
<FireIcon className="h-8 w-8 text-orange-500" />
|
|
<div>
|
|
<p className="text-3xl font-black text-gray-950">{weekCalories}</p>
|
|
<p className="text-sm font-bold text-gray-400">kcal Sportbonus</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<div className="flex items-center gap-3">
|
|
<SparklesIcon className="h-8 w-8 text-orange-500" />
|
|
<div>
|
|
<p className="text-3xl font-black text-gray-950">{pointsLabel(weekPoints)}</p>
|
|
<p className="text-sm font-bold text-gray-400">aktueller Status</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<h2 className="text-xl font-black text-gray-950">Woche</h2>
|
|
<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>
|
|
<div className="mt-5 grid grid-cols-7 gap-2">
|
|
{weeklyDays.map((day) => {
|
|
const active = day.key === selectedDate;
|
|
return (
|
|
<button
|
|
key={day.key}
|
|
type="button"
|
|
onClick={() => setSelectedDate(day.key)}
|
|
className={`min-h-20 rounded-3xl p-2 text-center ${active ? "bg-gray-950 text-white" : "bg-gray-50 text-gray-400"}`}
|
|
>
|
|
<p className="text-sm font-black uppercase">{day.label}</p>
|
|
<p className="text-xl font-black">{day.day}</p>
|
|
<p className={`mt-1 text-xs font-black ${active ? "text-emerald-300" : "text-gray-300"}`}>{day.points} P</p>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
|
|
<div 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">
|
|
<h2 className="text-xl font-black text-gray-950">Fitness-Katalog</h2>
|
|
<div className="flex gap-2">
|
|
{(["all", "fitbit", "manual"] as const).map((key) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => setTemplateCategory(key)}
|
|
className={`rounded-full px-4 py-2 text-xs font-black ${templateCategory === key ? "bg-gray-950 text-white" : "bg-gray-100 text-gray-500"}`}
|
|
>
|
|
{key === "all" ? "Alle" : key === "fitbit" ? "Fitbit" : "Eigene"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<input
|
|
value={templateQuery}
|
|
onChange={(event) => setTemplateQuery(event.target.value)}
|
|
placeholder="Aktivität suchen"
|
|
className="mt-4 w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
|
/>
|
|
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
|
{filteredTemplates.map((template) => (
|
|
<button
|
|
key={template.id}
|
|
type="button"
|
|
onClick={() => logTemplate(template)}
|
|
disabled={addLogMutation.isPending}
|
|
className="rounded-3xl bg-gray-50 p-4 text-left hover:bg-gray-100 disabled:opacity-50"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-lg font-black text-gray-950">{template.name}</p>
|
|
<p className="mt-1 text-sm font-bold text-gray-400">
|
|
{template.defaultMinutes} Minuten · {categoryLabel(template.category)}
|
|
</p>
|
|
<p className="text-xs font-bold text-gray-300">ca. {template.defaultCalories} kcal Sportbonus</p>
|
|
</div>
|
|
<span className={`rounded-full px-3 py-1 text-sm font-black ${categoryColor(template.category)}`}>
|
|
{template.defaultPoints} P
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
{!filteredTemplates.length && !templatesQuery.isLoading ? (
|
|
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Keine passende Vorlage gefunden.</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<aside className="space-y-4">
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Aktivität nacherfassen</h2>
|
|
<div className="mt-4 grid gap-3">
|
|
<input value={customName} onChange={(event) => setCustomName(event.target.value)} placeholder="z. B. 15 Minuten Yoga" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|
<select value={customSource} onChange={(event) => setCustomSource(event.target.value as ActivitySource)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
|
<option value="manual">Manuell erfasst</option>
|
|
<option value="fitbit">Fitbit nachgetragen</option>
|
|
</select>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input value={customMinutes} onChange={(event) => setCustomMinutes(event.target.value)} placeholder="Minuten" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|
<input value={customPoints} onChange={(event) => setCustomPoints(event.target.value)} placeholder="Punkte" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|
<input value={customCalories} onChange={(event) => setCustomCalories(event.target.value)} placeholder="kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|
</div>
|
|
<textarea value={customNote} onChange={(event) => setCustomNote(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={addCustomLog}
|
|
disabled={addLogMutation.isPending}
|
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-emerald-600 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Für {new Date(`${selectedDate}T12:00:00`).toLocaleDateString("de-DE")} eintragen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={saveCustomTemplate}
|
|
disabled={saveTemplateMutation.isPending}
|
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Zusätzlich als Vorlage speichern
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Heute eingetragen</h2>
|
|
<div className="mt-4 space-y-2">
|
|
{todayLogs.length ? todayLogs.map((log) => (
|
|
<div key={log.id} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
|
|
<FireIcon className="h-6 w-6 text-orange-500" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-black text-gray-950">{log.name}</p>
|
|
<p className="text-xs font-bold text-gray-400">
|
|
{log.minutes} min · {log.points} Punkte · {log.calories ?? 0} kcal · {sourceLabel(log.source)}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => deleteLogMutation.mutate({ id: log.id })}
|
|
className="rounded-full p-2 text-red-500 hover:bg-red-50"
|
|
aria-label="Eintrag löschen"
|
|
title="Eintrag löschen"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)) : (
|
|
<p className="rounded-2xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Aktivität für diesen Tag.</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|