hellth-hub/apps/admin/app/(authed)/hellfireclub/page.tsx
Sebastian Mayer 2436dd9cac Initial commit: Hellth Hub monorepo
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>
2026-05-27 22:15:16 +02:00

477 lines
22 KiB
TypeScript

"use client";
import {
BoltIcon,
CheckCircleIcon,
FireIcon,
GlobeAltIcon,
MagnifyingGlassIcon,
MapPinIcon,
PlusIcon,
TrophyIcon,
UserGroupIcon,
} from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import {
ClubActivityCategory,
ClubScope,
GermanState,
clubCategories,
defaultClubProfile,
germanStates,
todayKey,
} from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
type ClubForm = {
title: string;
category: ClubActivityCategory;
scope: ClubScope;
date: string;
time: string;
durationMinutes: string;
points: string;
note: string;
};
type ClubProfileView = {
userId: string;
state: string;
city?: string | null;
latitude?: number | null;
longitude?: number | null;
privacyMode: "city" | "state";
};
const initialForm: ClubForm = {
title: "",
category: "walk",
scope: "state",
date: todayKey(),
time: "18:00",
durationMinutes: "60",
points: "100",
note: "",
};
function formatDateTime(value: string) {
return new Date(value).toLocaleString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function categoryLabel(category: string) {
return clubCategories.find((item) => item.key === category)?.label ?? "Sonstiges";
}
function categoryStyle(category: string) {
if (category === "walk") return "bg-emerald-50 text-emerald-700";
if (category === "run") return "bg-orange-50 text-orange-700";
if (category === "mobility") return "bg-blue-50 text-blue-700";
if (category === "strength") return "bg-purple-50 text-purple-700";
if (category === "bike") return "bg-cyan-50 text-cyan-700";
return "bg-gray-100 text-gray-700";
}
function scopeLabel(scope: ClubScope) {
if (scope === "local") return "Lokal";
if (scope === "state") return "Bundesland";
return "Deutschlandweit";
}
function distanceKm(
profile: { latitude?: number | null; longitude?: number | null },
activity: { latitude?: number | null; longitude?: number | null },
) {
if (
profile.latitude === undefined ||
profile.latitude === null ||
profile.longitude === undefined ||
profile.longitude === null ||
activity.latitude === undefined ||
activity.latitude === null ||
activity.longitude === undefined ||
activity.longitude === null
) {
return undefined;
}
const toRad = (value: number) => (value * Math.PI) / 180;
const earthKm = 6371;
const deltaLat = toRad(activity.latitude - profile.latitude);
const deltaLon = toRad(activity.longitude - profile.longitude);
const lat1 = toRad(profile.latitude);
const lat2 = toRad(activity.latitude);
const haversine =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return Math.round(earthKm * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)));
}
function locationLabel(profile: { state: string; city?: string | null; privacyMode: string }, scope: ClubScope) {
if (scope === "nationwide") return "Deutschlandweit";
if (scope === "state" || profile.privacyMode === "state") return profile.state;
return profile.city?.trim() || profile.state;
}
export default function HellfireclubPage() {
const utils = trpc.useUtils();
const [query, setQuery] = useState("");
const [category, setCategory] = useState<ClubActivityCategory | "all">("all");
const [scope, setScope] = useState<ClubScope>("state");
const [form, setForm] = useState<ClubForm>(initialForm);
const [status, setStatus] = useState<string | null>(null);
const [locationStatus, setLocationStatus] = useState<string | null>(null);
const profileQuery = trpc.health.clubProfile.useQuery();
const activitiesQuery = trpc.health.clubActivities.useQuery({});
const pointDefaultsQuery = trpc.health.pointDefaults.useQuery();
const leaderboardQuery = trpc.health.clubLeaderboard.useQuery({ days: 90 });
const upsertProfileMutation = trpc.health.upsertClubProfile.useMutation({
onSuccess: () => utils.health.clubProfile.invalidate(),
});
const createActivityMutation = trpc.health.createClubActivity.useMutation({
onSuccess: async () => {
await utils.health.clubActivities.invalidate();
setForm(initialForm);
setStatus("Aktivität erstellt. Andere User können jetzt passend zu deinem Gebiet mitmachen.");
},
});
const joinActivityMutation = trpc.health.joinClubActivity.useMutation({
onSuccess: async () => {
await utils.health.clubActivities.invalidate();
setStatus("Du bist dabei. Die Aktivität ist in deinem Hellfireclub vorgemerkt.");
},
});
const completeActivityMutation = trpc.health.completeClubActivity.useMutation({
onSuccess: async () => {
await Promise.all([
utils.health.clubActivities.invalidate(),
utils.health.clubLeaderboard.invalidate(),
utils.health.activityWeek.invalidate(),
utils.health.overview.invalidate(),
]);
setStatus("Punkte gutgeschrieben und als Aktivität gespeichert.");
},
});
const rawProfile = profileQuery.data;
const clubActivityDefaultPoints = pointDefaultsQuery.data?.clubActivityDefaultPoints ?? 100;
const profile: ClubProfileView = {
userId: rawProfile?.userId ?? "",
state: rawProfile?.state ?? defaultClubProfile.state,
city: rawProfile?.city ?? defaultClubProfile.city,
latitude: rawProfile?.latitude == null ? null : Number(rawProfile.latitude),
longitude: rawProfile?.longitude == null ? null : Number(rawProfile.longitude),
privacyMode: (rawProfile?.privacyMode ?? defaultClubProfile.privacyMode) as "city" | "state",
};
const activities = activitiesQuery.data ?? [];
function updateProfile(patch: Partial<ClubProfileView>) {
const next = { ...profile, ...patch };
upsertProfileMutation.mutate({
state: next.state,
city: next.city ?? null,
latitude: next.latitude == null ? null : Number(next.latitude),
longitude: next.longitude == null ? null : Number(next.longitude),
privacyMode: next.privacyMode as "city" | "state",
});
}
function requestLocation() {
if (!navigator.geolocation) {
setLocationStatus("Dein Browser unterstützt keine Standortfreigabe.");
return;
}
setLocationStatus("Standort wird abgefragt...");
navigator.geolocation.getCurrentPosition(
(position) => {
updateProfile({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
setLocationStatus("Standort gespeichert. Sichtbar bleibt nur Stadt oder Bundesland.");
},
() => setLocationStatus("Standort konnte nicht gelesen werden. Bundesland-Filter funktioniert weiterhin."),
{ enableHighAccuracy: false, maximumAge: 1000 * 60 * 30, timeout: 8000 },
);
}
const filteredActivities = useMemo(() => {
const needle = query.trim().toLowerCase();
return activities
.filter((activity) => {
const activityScope = (activity.scope ?? "nationwide") as ClubScope;
if (scope === "nationwide" || activityScope === "nationwide") return true;
if (scope === "state") return activity.state === profile.state;
if (activity.state !== profile.state) return false;
const distance = distanceKm(profile, activity);
if (distance !== undefined) return distance <= 30;
return Boolean(activity.city && profile.city && activity.city.toLowerCase() === profile.city.toLowerCase());
})
.filter((activity) => category === "all" || activity.category === category)
.filter((activity) => {
if (!needle) return true;
return [activity.title, activity.hostName, activity.note, activity.locationLabel, activity.city, activity.state, categoryLabel(activity.category)]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(needle));
})
.sort((a, b) => {
const distanceA = distanceKm(profile, a) ?? Number.MAX_SAFE_INTEGER;
const distanceB = distanceKm(profile, b) ?? Number.MAX_SAFE_INTEGER;
if (scope === "local" && distanceA !== distanceB) return distanceA - distanceB;
return new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime();
});
}, [activities, category, profile, query, scope]);
const leaderboard = leaderboardQuery.data ?? [];
function createActivity() {
if (!form.title.trim()) return;
createActivityMutation.mutate({
title: form.title.trim(),
category: form.category,
scope: form.scope,
state: profile.state,
city: profile.city ?? null,
latitude: profile.latitude == null ? null : Number(profile.latitude),
longitude: profile.longitude == null ? null : Number(profile.longitude),
locationLabel: locationLabel(profile, form.scope),
startsAt: `${form.date}T${form.time}:00.000Z`,
durationMinutes: Number(form.durationMinutes) || 30,
points: Number(form.points) || clubActivityDefaultPoints,
note: form.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-red-300">Hellfireclub</p>
<h1 className="mt-2 text-4xl font-black tracking-normal">Gemeinsam getrennt aktiv</h1>
<p className="mt-3 max-w-3xl text-sm font-semibold text-white/60">
Lokale Challenges, Bundesland-Events und deutschlandweite Aktivitäten. Jetzt persistent über API und Datenbank.
</p>
</div>
<div className="grid h-28 w-28 place-items-center rounded-full bg-red-500 text-white ring-[12px] ring-red-500/20">
<FireIcon className="h-14 w-14" />
</div>
</div>
</header>
<section className="grid gap-4 lg:grid-cols-[1fr_0.8fr]">
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-3 md:flex-row">
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-full bg-gray-100 px-4 py-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Aktivität, Ort oder Kategorie suchen"
className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none"
/>
</div>
<select
value={category}
onChange={(event) => setCategory(event.target.value as ClubActivityCategory | "all")}
className="rounded-full border border-gray-200 bg-white px-4 py-3 text-sm font-black text-gray-950"
>
<option value="all">Alle Kategorien</option>
{clubCategories.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}
</select>
</div>
<div className="mt-4 grid grid-cols-3 gap-2 rounded-full bg-gray-100 p-1">
{(["local", "state", "nationwide"] as ClubScope[]).map((item) => (
<button
key={item}
type="button"
onClick={() => setScope(item)}
className={`rounded-full px-3 py-2 text-xs font-black transition ${scope === item ? "bg-gray-950 text-white" : "text-gray-500"}`}
>
{scopeLabel(item)}
</button>
))}
</div>
<div className="mt-5 space-y-3">
{filteredActivities.map((activity) => {
const participant = activity.participants.find((item) => item.userId === profile.userId);
const completed = Boolean(participant?.completedAt);
const distance = distanceKm(profile, activity);
return (
<article key={activity.id} className="rounded-[2rem] bg-gray-50 p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-3 py-1 text-xs font-black ${categoryStyle(activity.category)}`}>
{categoryLabel(activity.category)}
</span>
<span className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-400">
{activity.points} Punkte
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-gray-500">
{activity.scope === "nationwide" ? <GlobeAltIcon className="h-4 w-4" /> : <MapPinIcon className="h-4 w-4" />}
{activity.locationLabel ?? scopeLabel((activity.scope ?? "nationwide") as ClubScope)}
{distance !== undefined ? ` · ${distance} km` : ""}
</span>
</div>
<h2 className="mt-3 text-2xl font-black text-gray-950">{activity.title}</h2>
<p className="mt-1 text-sm font-bold text-gray-400">
{activity.hostName} möchte am {formatDateTime(activity.startsAt)} für {activity.durationMinutes} Minuten los.
</p>
{activity.note ? <p className="mt-3 text-sm font-semibold text-gray-500">{activity.note}</p> : null}
</div>
<div className="flex min-w-44 flex-col gap-2">
<button
type="button"
disabled={Boolean(participant)}
onClick={() => joinActivityMutation.mutate({ id: activity.id })}
className="rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:bg-gray-200 disabled:text-gray-500"
>
{participant ? "Du bist dabei" : "Mitgehen"}
</button>
<button
type="button"
disabled={completed}
onClick={() => completeActivityMutation.mutate({ id: activity.id })}
className="rounded-full bg-emerald-600 px-4 py-3 text-sm font-black text-white disabled:bg-emerald-100 disabled:text-emerald-700"
>
{completed ? "Abgeschlossen" : "Abschließen"}
</button>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm font-bold text-gray-400">
<UserGroupIcon className="h-5 w-5" />
{activity.participants.length || 0} dabei
{activity.participants.length > 0 ? <span className="text-gray-300">{activity.participants.map((item) => item.displayName).join(", ")}</span> : null}
</div>
</article>
);
})}
{!filteredActivities.length ? <p className="rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">Für diesen Filter gibt es noch keine Aktivitäten.</p> : null}
</div>
</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">
<MapPinIcon className="h-7 w-7 text-red-500" />
<h2 className="text-xl font-black text-gray-950">Club-Profil</h2>
</div>
<div className="mt-4 grid gap-3">
<input
value={profile.city ?? ""}
onChange={(event) => updateProfile({ city: event.target.value })}
placeholder="Stadt, z. B. Augsburg"
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
/>
<select
value={profile.state}
onChange={(event) => updateProfile({ state: event.target.value as GermanState })}
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
>
{germanStates.map((state) => <option key={state} value={state}>{state}</option>)}
</select>
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-gray-100 p-1">
{(["city", "state"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => updateProfile({ privacyMode: mode })}
className={`rounded-xl px-3 py-2 text-xs font-black ${profile.privacyMode === mode ? "bg-white text-gray-950 shadow-sm" : "text-gray-500"}`}
>
{mode === "city" ? "Stadt zeigen" : "Bundesland"}
</button>
))}
</div>
<button type="button" onClick={requestLocation} className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white">
<MapPinIcon className="h-5 w-5" />
Standort verwenden
</button>
{locationStatus ? <p className="text-xs font-bold text-gray-500">{locationStatus}</p> : null}
</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">Aktivität erstellen</h2>
<div className="mt-4 grid gap-3">
<input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} placeholder="z. B. Eine Stunde spazieren gehen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<select value={form.category} onChange={(event) => setForm((current) => ({ ...current, category: event.target.value as ClubActivityCategory }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
{clubCategories.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}
</select>
<select value={form.scope} onChange={(event) => setForm((current) => ({ ...current, scope: event.target.value as ClubScope }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
<option value="local">Lokal in deiner Nähe</option>
<option value="state">Im Bundesland</option>
<option value="nationwide">Deutschlandweit</option>
</select>
<div className="grid grid-cols-2 gap-2">
<input type="date" value={form.date} onChange={(event) => setForm((current) => ({ ...current, date: event.target.value }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input type="time" value={form.time} onChange={(event) => setForm((current) => ({ ...current, time: event.target.value }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={form.durationMinutes} onChange={(event) => setForm((current) => ({ ...current, durationMinutes: event.target.value }))} placeholder="Minuten" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<input value={form.points} onChange={(event) => setForm((current) => ({ ...current, points: event.target.value }))} placeholder="Punkte" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
</div>
<textarea value={form.note} onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))} placeholder="Notiz, Treffpunkt oder Motivation" className="min-h-24 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
<button type="button" onClick={createActivity} className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white">
<PlusIcon className="h-5 w-5" />
Einladen
</button>
</div>
</section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<TrophyIcon className="h-8 w-8 text-amber-500" />
<h2 className="text-xl font-black text-gray-950">Highscore</h2>
</div>
<div className="mt-4 space-y-2">
{leaderboard.length ? leaderboard.map((entry, index) => (
<div key={entry.userKey} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
<div className="grid h-9 w-9 place-items-center rounded-full bg-gray-950 text-sm font-black text-white">{index + 1}</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-black text-gray-950">{entry.name}</p>
<p className="text-xs font-bold text-gray-400">{entry.completed} Punkte-Einträge</p>
</div>
<div className="text-right">
<p className="text-lg font-black text-gray-950">{entry.points}</p>
<p className="text-xs font-bold text-gray-400">Punkte</p>
</div>
</div>
)) : (
<p className="rounded-2xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Punkte gesammelt.</p>
)}
</div>
</section>
{status ? (
<div className="flex gap-2 rounded-3xl bg-emerald-50 p-4 text-sm font-bold text-emerald-800">
<CheckCircleIcon className="h-5 w-5 shrink-0" />
{status}
</div>
) : null}
</aside>
</section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start gap-3">
<BoltIcon className="h-7 w-7 text-red-500" />
<div>
<h2 className="text-lg font-black text-gray-950">Persistenter Club</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">
Aktivitäten, Teilnahmen, Abschlüsse und Punkte werden jetzt in der Datenbank gespeichert.
</p>
</div>
</div>
</section>
</div>
);
}