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

372 lines
17 KiB
TypeScript
Raw Permalink Normal View History

"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>
);
}